Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb2a41d068 | |||
| 820a4a30ab | |||
| c9e49b4b95 | |||
| 0ba9443ef4 | |||
| 7f8c968d6a | |||
| 4fee88329b | |||
| 66c30de2db | |||
| 436feb7f7c | |||
| 7d0fde3acc | |||
| 939883c9cd | |||
| 99f3d59ff1 | |||
| 965f044e0c | |||
| 6a3bd37eb6 |
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.25.4'
|
||||
GO_VERSION: '1.25.5'
|
||||
NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -16,22 +16,12 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
## Lossless Audio Checker
|
||||
|
||||
A simple utility for verifying the authenticity of FLAC files.
|
||||
|
||||
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
|
||||
|
||||
#
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
## Other project
|
||||
|
||||
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
|
||||
|
||||
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
|
||||
|
||||
[](https://ko-fi.com/afkarxyz)
|
||||
|
||||
@@ -140,8 +140,8 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
|
||||
if req.OutputDir == "" {
|
||||
req.OutputDir = "."
|
||||
} else {
|
||||
// Sanitize output directory path to remove invalid characters
|
||||
req.OutputDir = backend.SanitizeFolderPath(req.OutputDir)
|
||||
// Only normalize path separators, don't sanitize user's existing folder names
|
||||
req.OutputDir = backend.NormalizePath(req.OutputDir)
|
||||
}
|
||||
|
||||
if req.AudioFormat == "" {
|
||||
@@ -608,6 +608,11 @@ func (a *App) IsFFmpegInstalled() (bool, error) {
|
||||
return backend.IsFFmpegInstalled()
|
||||
}
|
||||
|
||||
// IsFFprobeInstalled checks if ffprobe is installed
|
||||
func (a *App) IsFFprobeInstalled() (bool, error) {
|
||||
return backend.IsFFprobeInstalled()
|
||||
}
|
||||
|
||||
// GetFFmpegPath returns the path to ffmpeg
|
||||
func (a *App) GetFFmpegPath() (string, error) {
|
||||
return backend.GetFFmpegPath()
|
||||
@@ -641,26 +646,12 @@ func (a *App) DownloadFFmpeg() DownloadFFmpegResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// InstallFFmpegFromFile installs ffmpeg from a local file path
|
||||
func (a *App) InstallFFmpegFromFile(filePath string) DownloadFFmpegResponse {
|
||||
err := backend.InstallFFmpegFromFile(filePath)
|
||||
if err != nil {
|
||||
return DownloadFFmpegResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}
|
||||
}
|
||||
return DownloadFFmpegResponse{
|
||||
Success: true,
|
||||
Message: "FFmpeg installed successfully from file",
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
OutputFormat string `json:"output_format"`
|
||||
Bitrate string `json:"bitrate"`
|
||||
Codec string `json:"codec"` // For m4a: "aac" (lossy) or "alac" (lossless)
|
||||
}
|
||||
|
||||
// ConvertAudio converts audio files using ffmpeg
|
||||
@@ -669,6 +660,7 @@ func (a *App) ConvertAudio(req ConvertAudioRequest) ([]backend.ConvertAudioResul
|
||||
InputFiles: req.InputFiles,
|
||||
OutputFormat: req.OutputFormat,
|
||||
Bitrate: req.Bitrate,
|
||||
Codec: req.Codec,
|
||||
}
|
||||
return backend.ConvertAudio(backendReq)
|
||||
}
|
||||
@@ -681,3 +673,79 @@ func (a *App) SelectAudioFiles() ([]string, error) {
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// GetFileSizes returns file sizes for a list of file paths
|
||||
func (a *App) GetFileSizes(files []string) map[string]int64 {
|
||||
return backend.GetFileSizes(files)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
// AnalysisResult contains the audio analysis data
|
||||
type AnalysisResult struct {
|
||||
FilePath string `json:"file_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
SampleRate uint32 `json:"sample_rate"`
|
||||
Channels uint8 `json:"channels"`
|
||||
BitsPerSample uint8 `json:"bits_per_sample"`
|
||||
@@ -30,6 +31,12 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
return nil, fmt.Errorf("file does not exist: %s", filepath)
|
||||
}
|
||||
|
||||
// Get file size
|
||||
fileInfo, err := os.Stat(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||
}
|
||||
|
||||
// Parse FLAC file
|
||||
f, err := flac.ParseFile(filepath)
|
||||
if err != nil {
|
||||
@@ -38,6 +45,7 @@ func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
|
||||
|
||||
result := &AnalysisResult{
|
||||
FilePath: filepath,
|
||||
FileSize: fileInfo.Size(),
|
||||
}
|
||||
|
||||
// Extract basic audio properties from STREAMINFO block
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadRes
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = SanitizeFolderPath(outputDir)
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
|
||||
+221
-163
@@ -13,7 +13,6 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ulikunitz/xz"
|
||||
)
|
||||
@@ -30,7 +29,8 @@ func decodeBase64(encoded string) (string, error) {
|
||||
const (
|
||||
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
|
||||
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
|
||||
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZm1wZWcvemlw"
|
||||
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
|
||||
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
|
||||
)
|
||||
|
||||
// GetFFmpegDir returns the directory where ffmpeg should be stored
|
||||
@@ -57,6 +57,40 @@ func GetFFmpegPath() (string, error) {
|
||||
return filepath.Join(ffmpegDir, ffmpegName), nil
|
||||
}
|
||||
|
||||
// GetFFprobePath returns the full path to the ffprobe executable in app directory
|
||||
func GetFFprobePath() (string, error) {
|
||||
ffmpegDir, err := GetFFmpegDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ffprobeName := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffprobeName = "ffprobe.exe"
|
||||
}
|
||||
|
||||
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
|
||||
if _, err := os.Stat(ffprobePath); err == nil {
|
||||
return ffprobePath, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffprobe not found in app directory")
|
||||
}
|
||||
|
||||
// IsFFprobeInstalled checks if ffprobe is installed in the app directory
|
||||
func IsFFprobeInstalled() (bool, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Verify it's executable
|
||||
cmd := exec.Command(ffprobePath, "-version")
|
||||
setHideWindow(cmd)
|
||||
err = cmd.Run()
|
||||
return err == nil, nil
|
||||
}
|
||||
|
||||
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
|
||||
func IsFFmpegInstalled() (bool, error) {
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
@@ -92,15 +126,49 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||
}
|
||||
|
||||
// Get the appropriate URL for the current OS
|
||||
// For macOS, download ffmpeg and ffprobe separately (only if not already installed)
|
||||
if runtime.GOOS == "darwin" {
|
||||
ffmpegInstalled, _ := IsFFmpegInstalled()
|
||||
ffprobeInstalled, _ := IsFFprobeInstalled()
|
||||
|
||||
if !ffmpegInstalled && !ffprobeInstalled {
|
||||
// Download both
|
||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
|
||||
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
|
||||
return fmt.Errorf("failed to download ffprobe: %w", err)
|
||||
}
|
||||
} else if !ffmpegInstalled {
|
||||
// Only download ffmpeg
|
||||
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
|
||||
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !ffprobeInstalled {
|
||||
// Only download ffprobe
|
||||
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
|
||||
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
|
||||
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return fmt.Errorf("failed to download ffprobe: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For Windows/Linux: single archive contains both ffmpeg and ffprobe
|
||||
var encodedURL string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
encodedURL = ffmpegWindowsURL
|
||||
case "linux":
|
||||
encodedURL = ffmpegLinuxURL
|
||||
case "darwin":
|
||||
encodedURL = ffmpegMacOSURL
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
}
|
||||
@@ -113,6 +181,15 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
|
||||
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
|
||||
|
||||
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadAndExtract downloads a file and extracts it
|
||||
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
|
||||
// Create temporary file for download
|
||||
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
|
||||
if err != nil {
|
||||
@@ -124,12 +201,12 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
// Download the file
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download ffmpeg: %w", err)
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to download ffmpeg: HTTP %d", resp.StatusCode)
|
||||
return fmt.Errorf("failed to download: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
@@ -146,8 +223,10 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
}
|
||||
downloaded += int64(n)
|
||||
if totalSize > 0 && progressCallback != nil {
|
||||
progress := int(float64(downloaded) / float64(totalSize) * 100)
|
||||
progressCallback(progress)
|
||||
// Scale progress between progressStart and progressEnd
|
||||
rawProgress := float64(downloaded) / float64(totalSize)
|
||||
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
|
||||
progressCallback(scaledProgress)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
@@ -162,18 +241,14 @@ func DownloadFFmpeg(progressCallback func(int)) error {
|
||||
|
||||
fmt.Printf("[FFmpeg] Download complete, extracting...\n")
|
||||
|
||||
// Extract the archive
|
||||
switch runtime.GOOS {
|
||||
case "windows", "darwin":
|
||||
return extractZip(tmpFile.Name(), ffmpegDir)
|
||||
case "linux":
|
||||
return extractTarXz(tmpFile.Name(), ffmpegDir)
|
||||
default:
|
||||
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
||||
// Extract the archive based on file type
|
||||
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
|
||||
return extractTarXz(tmpFile.Name(), destDir)
|
||||
}
|
||||
return extractZip(tmpFile.Name(), destDir)
|
||||
}
|
||||
|
||||
// extractZip extracts ffmpeg from a zip archive
|
||||
// extractZip extracts ffmpeg and ffprobe from a zip archive (skips ffplay)
|
||||
func extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
@@ -182,44 +257,73 @@ func extractZip(zipPath, destDir string) error {
|
||||
defer r.Close()
|
||||
|
||||
ffmpegName := "ffmpeg"
|
||||
ffprobeName := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
ffmpegName = "ffmpeg.exe"
|
||||
ffprobeName = "ffprobe.exe"
|
||||
}
|
||||
|
||||
destPath := filepath.Join(destDir, ffmpegName)
|
||||
foundFFmpeg := false
|
||||
foundFFprobe := false
|
||||
|
||||
for _, f := range r.File {
|
||||
// Look for ffmpeg executable in any subdirectory
|
||||
baseName := filepath.Base(f.Name)
|
||||
if baseName == ffmpegName && !f.FileInfo().IsDir() {
|
||||
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file in zip: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
return nil
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
var destPath string
|
||||
if baseName == ffmpegName {
|
||||
destPath = filepath.Join(destDir, ffmpegName)
|
||||
foundFFmpeg = true
|
||||
} else if baseName == ffprobeName {
|
||||
destPath = filepath.Join(destDir, ffprobeName)
|
||||
foundFFprobe = true
|
||||
} else {
|
||||
// Skip ffplay and other files
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file in zip: %w", err)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
rc.Close()
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
return fmt.Errorf("ffmpeg executable not found in archive")
|
||||
// At least one of ffmpeg or ffprobe should be found
|
||||
if !foundFFmpeg && !foundFFprobe {
|
||||
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||
}
|
||||
|
||||
if foundFFmpeg {
|
||||
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
|
||||
}
|
||||
if foundFFprobe {
|
||||
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarXz extracts ffmpeg from a tar.xz archive
|
||||
// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive (skips ffplay)
|
||||
func extractTarXz(tarXzPath, destDir string) error {
|
||||
file, err := os.Open(tarXzPath)
|
||||
if err != nil {
|
||||
@@ -235,7 +339,9 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
tarReader := tar.NewReader(xzReader)
|
||||
|
||||
ffmpegName := "ffmpeg"
|
||||
destPath := filepath.Join(destDir, ffmpegName)
|
||||
ffprobeName := "ffprobe"
|
||||
foundFFmpeg := false
|
||||
foundFFprobe := false
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
@@ -246,34 +352,62 @@ func extractTarXz(tarXzPath, destDir string) error {
|
||||
return fmt.Errorf("failed to read tar: %w", err)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(header.Name)
|
||||
if baseName == ffmpegName && header.Typeflag == tar.TypeReg {
|
||||
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, err = io.Copy(outFile, tarReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
return nil
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
baseName := filepath.Base(header.Name)
|
||||
var destPath string
|
||||
|
||||
if baseName == ffmpegName {
|
||||
destPath = filepath.Join(destDir, ffmpegName)
|
||||
foundFFmpeg = true
|
||||
} else if baseName == ffprobeName {
|
||||
destPath = filepath.Join(destDir, ffprobeName)
|
||||
foundFFprobe = true
|
||||
} else {
|
||||
// Skip ffplay and other files
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
|
||||
|
||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, tarReader)
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||
}
|
||||
|
||||
return fmt.Errorf("ffmpeg executable not found in archive")
|
||||
// At least one of ffmpeg or ffprobe should be found
|
||||
if !foundFFmpeg && !foundFFprobe {
|
||||
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
|
||||
}
|
||||
|
||||
if foundFFmpeg {
|
||||
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
|
||||
}
|
||||
if foundFFprobe {
|
||||
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertAudioRequest represents a request to convert audio files
|
||||
type ConvertAudioRequest struct {
|
||||
InputFiles []string `json:"input_files"`
|
||||
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
|
||||
@@ -348,7 +482,7 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
// Extract cover art and lyrics from input file before conversion
|
||||
var coverArtPath string
|
||||
var lyrics string
|
||||
|
||||
|
||||
coverArtPath, _ = ExtractCoverArt(inputFile)
|
||||
lyrics, err = ExtractLyrics(inputFile)
|
||||
if err != nil {
|
||||
@@ -378,12 +512,28 @@ func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
|
||||
// Map video stream if exists (for cover art)
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy")
|
||||
case "m4a":
|
||||
args = append(args,
|
||||
"-codec:a", "aac",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
)
|
||||
// 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,
|
||||
"-codec:a", "aac",
|
||||
"-b:a", req.Bitrate,
|
||||
"-map", "0:a", // Map audio stream
|
||||
"-map_metadata", "0", // Copy all metadata
|
||||
)
|
||||
}
|
||||
// Map video stream for cover art in M4A
|
||||
args = append(args, "-map", "0:v?", "-c:v", "copy", "-disposition:v:0", "attached_pic")
|
||||
}
|
||||
@@ -463,95 +613,3 @@ func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
|
||||
Size: info.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InstallFFmpegFromFile installs ffmpeg from a local file path
|
||||
func InstallFFmpegFromFile(filePath string) error {
|
||||
// Check if file exists
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file does not exist: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's a regular file (not a directory)
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("path is a directory, not a file")
|
||||
}
|
||||
|
||||
// Verify it's likely an ffmpeg executable by checking the filename
|
||||
fileName := strings.ToLower(filepath.Base(filePath))
|
||||
expectedName := "ffmpeg"
|
||||
if runtime.GOOS == "windows" {
|
||||
expectedName = "ffmpeg.exe"
|
||||
}
|
||||
|
||||
if fileName != expectedName && !strings.Contains(fileName, "ffmpeg") {
|
||||
return fmt.Errorf("file does not appear to be an ffmpeg executable (expected name containing 'ffmpeg')")
|
||||
}
|
||||
|
||||
// Get destination path
|
||||
ffmpegPath, err := GetFFmpegPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get ffmpeg path: %w", err)
|
||||
}
|
||||
|
||||
ffmpegDir := filepath.Dir(ffmpegPath)
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
|
||||
}
|
||||
|
||||
// Copy file to destination
|
||||
sourceFile, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file: %w", err)
|
||||
}
|
||||
|
||||
destFile, err := os.OpenFile(ffmpegPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||
if err != nil {
|
||||
sourceFile.Close()
|
||||
return fmt.Errorf("failed to create destination file: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
sourceFile.Close()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
return fmt.Errorf("failed to copy file: %w", err)
|
||||
}
|
||||
|
||||
// Ensure all data is written to disk
|
||||
if err := destFile.Sync(); err != nil {
|
||||
destFile.Close()
|
||||
return fmt.Errorf("failed to sync file: %w", err)
|
||||
}
|
||||
destFile.Close()
|
||||
|
||||
// On Windows, file may still be locked by antivirus or system
|
||||
// Wait a bit and retry verification
|
||||
maxRetries := 3
|
||||
retryDelay := 500 * time.Millisecond
|
||||
|
||||
var verifyErr error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if i > 0 {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
cmd := exec.Command(ffmpegPath, "-version")
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
verifyErr = cmd.Run()
|
||||
if verifyErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if verifyErr != nil {
|
||||
return fmt.Errorf("file copied but ffmpeg verification failed after %d attempts: %w", maxRetries, verifyErr)
|
||||
}
|
||||
|
||||
fmt.Printf("[FFmpeg] Successfully installed from: %s\n", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
"github.com/go-flac/go-flac"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// readMetadataWithFFprobe reads metadata from any audio file using ffprobe
|
||||
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
|
||||
ffprobePath, err := GetFFprobePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use ffprobe to get metadata in JSON format (both format and stream tags)
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
filePath,
|
||||
)
|
||||
|
||||
// Hide console window on Windows
|
||||
setHideWindow(cmd)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
var result struct {
|
||||
Format struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"format"`
|
||||
Streams []struct {
|
||||
Tags map[string]string `json:"tags"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
|
||||
// Merge tags from format and streams (format tags take priority)
|
||||
allTags := make(map[string]string)
|
||||
|
||||
// First add stream tags
|
||||
for _, stream := range result.Streams {
|
||||
for key, value := range stream.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Then add format tags (overwrite stream tags)
|
||||
for key, value := range result.Format.Tags {
|
||||
allTags[strings.ToLower(key)] = value
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
for key, value := range allTags {
|
||||
switch key {
|
||||
case "title":
|
||||
metadata.Title = value
|
||||
case "artist":
|
||||
metadata.Artist = value
|
||||
case "album":
|
||||
metadata.Album = value
|
||||
case "album_artist", "albumartist":
|
||||
metadata.AlbumArtist = value
|
||||
case "track":
|
||||
// Format might be "4" or "4/12"
|
||||
trackStr := strings.Split(value, "/")[0]
|
||||
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||
metadata.TrackNumber = num
|
||||
}
|
||||
case "disc":
|
||||
discStr := strings.Split(value, "/")[0]
|
||||
if num, err := strconv.Atoi(discStr); err == nil {
|
||||
metadata.DiscNumber = num
|
||||
}
|
||||
case "date", "year":
|
||||
if metadata.Year == "" || len(value) > len(metadata.Year) {
|
||||
metadata.Year = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// readM4aMetadata reads metadata from an M4A file using ffprobe
|
||||
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||
metadata, err := readMetadataWithFFprobe(filePath)
|
||||
if err != nil {
|
||||
return &AudioMetadata{}, nil
|
||||
}
|
||||
return metadata, 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
|
||||
}
|
||||
|
||||
// GetFileSizes returns file sizes for a list of file paths
|
||||
func GetFileSizes(files []string) map[string]int64 {
|
||||
result := make(map[string]int64)
|
||||
for _, filePath := range files {
|
||||
info, err := os.Stat(filePath)
|
||||
if err == nil {
|
||||
result[filePath] = info.Size()
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+22
-14
@@ -56,11 +56,11 @@ func BuildExpectedFilename(trackName, artistName, filenameFormat string, include
|
||||
func sanitizeFilename(name string) string {
|
||||
// Replace forward slash with space (more natural than underscore)
|
||||
sanitized := strings.ReplaceAll(name, "/", " ")
|
||||
|
||||
|
||||
// Remove other invalid filesystem characters (replace with space)
|
||||
re := regexp.MustCompile(`[<>:"\\|?*]`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
|
||||
// Remove control characters (0x00-0x1F, 0x7F)
|
||||
var result strings.Builder
|
||||
for _, r := range sanitized {
|
||||
@@ -79,49 +79,57 @@ func sanitizeFilename(name string) string {
|
||||
}
|
||||
// Remove emoji ranges (most emoji are in these ranges)
|
||||
if (r >= 0x1F300 && r <= 0x1F9FF) || // Miscellaneous Symbols and Pictographs, Emoticons
|
||||
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
|
||||
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
|
||||
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
|
||||
(r >= 0x2600 && r <= 0x26FF) || // Miscellaneous Symbols
|
||||
(r >= 0x2700 && r <= 0x27BF) || // Dingbats
|
||||
(r >= 0xFE00 && r <= 0xFE0F) || // Variation Selectors
|
||||
(r >= 0x1F900 && r <= 0x1F9FF) || // Supplemental Symbols and Pictographs
|
||||
(r >= 0x1F600 && r <= 0x1F64F) || // Emoticons
|
||||
(r >= 0x1F680 && r <= 0x1F6FF) || // Transport and Map Symbols
|
||||
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
|
||||
(r >= 0x1F1E0 && r <= 0x1F1FF) { // Regional Indicator Symbols (flags)
|
||||
continue
|
||||
}
|
||||
result.WriteRune(r)
|
||||
}
|
||||
|
||||
|
||||
sanitized = result.String()
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
|
||||
|
||||
// Remove leading/trailing dots and spaces (Windows doesn't allow these)
|
||||
sanitized = strings.Trim(sanitized, ". ")
|
||||
|
||||
|
||||
// Normalize consecutive spaces to single space
|
||||
re = regexp.MustCompile(`\s+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, " ")
|
||||
|
||||
|
||||
// Normalize consecutive underscores to single underscore
|
||||
re = regexp.MustCompile(`_+`)
|
||||
sanitized = re.ReplaceAllString(sanitized, "_")
|
||||
|
||||
|
||||
// Remove leading/trailing underscores and spaces
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
|
||||
|
||||
if sanitized == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
|
||||
// Ensure the result is valid UTF-8
|
||||
if !utf8.ValidString(sanitized) {
|
||||
// If invalid UTF-8, try to fix it
|
||||
sanitized = strings.ToValidUTF8(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
|
||||
// Use this only for NEW folders being created (artist names, album names, etc.)
|
||||
func SanitizeFolderPath(folderPath string) string {
|
||||
// Normalize all forward slashes to backslashes on Windows
|
||||
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
|
||||
|
||||
+11
-12
@@ -16,15 +16,15 @@ import (
|
||||
|
||||
// LRCLibResponse represents the LRCLIB API response
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration float64 `json:"duration"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Duration float64 `json:"duration"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
SyncedLyrics string `json:"syncedLyrics"`
|
||||
}
|
||||
|
||||
// LyricsLine represents a single line of lyrics
|
||||
@@ -255,7 +255,7 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
|
||||
|
||||
|
||||
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName)
|
||||
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
|
||||
return resp, "LRCLIB (simplified)", nil
|
||||
@@ -270,7 +270,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
return nil, "", fmt.Errorf("lyrics not found in any source")
|
||||
}
|
||||
|
||||
|
||||
// ConvertToLRC converts lyrics response to LRC format
|
||||
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
var sb strings.Builder
|
||||
@@ -364,7 +363,7 @@ func (c *LyricsClient) DownloadLyrics(req LyricsDownloadRequest) (*LyricsDownloa
|
||||
if outputDir == "" {
|
||||
outputDir = GetDefaultMusicPath()
|
||||
} else {
|
||||
outputDir = SanitizeFolderPath(outputDir)
|
||||
outputDir = NormalizePath(outputDir)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
pathfilepath "path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
id3v2 "github.com/bogem/id3v2/v2"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -18,5 +18,7 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
"registries": {
|
||||
"@lucide-animated": "https://lucide-animated.com/r/{name}.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,19 +17,17 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.12.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
@@ -39,18 +37,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^25.0.2",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"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",
|
||||
"sharp": "^0.34.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vite": "^7.2.7"
|
||||
"typescript-eslint": "^8.50.0",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
d4b3974abd992c8ff941c6fde9f62062
|
||||
c94dda3302d3338d7909ef5d634d0fde
|
||||
Generated
+366
-405
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 { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
|
||||
import { AudioConverterPage } from "@/components/AudioConverterPage";
|
||||
import { FileManagerPage } from "@/components/FileManagerPage";
|
||||
import { SettingsPage } from "@/components/SettingsPage";
|
||||
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
|
||||
import type { HistoryItem } from "@/components/FetchHistory";
|
||||
@@ -56,7 +57,7 @@ function App() {
|
||||
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
const CURRENT_VERSION = "6.8";
|
||||
const CURRENT_VERSION = "6.9";
|
||||
|
||||
const download = useDownload();
|
||||
const metadata = useMetadata();
|
||||
@@ -515,6 +516,8 @@ function App() {
|
||||
return <AudioAnalysisPage />;
|
||||
case "audio-converter":
|
||||
return <AudioConverterPage />;
|
||||
case "file-manager":
|
||||
return <FileManagerPage />;
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -148,7 +148,9 @@ export function AlbumInfo({
|
||||
<span>•</span>
|
||||
<span>{albumInfo.release_date}</span>
|
||||
<span>•</span>
|
||||
<span>{albumInfo.total_tracks} songs</span>
|
||||
<span>
|
||||
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
TrendingUp,
|
||||
FileAudio,
|
||||
Clock,
|
||||
Gauge
|
||||
Gauge,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
import type { AnalysisResult } from "@/types/api";
|
||||
|
||||
@@ -78,14 +79,22 @@ export function AudioAnalysis({
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
const 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];
|
||||
};
|
||||
|
||||
// Calculate Nyquist frequency (half of sample rate)
|
||||
const nyquistFreq = result.sample_rate / 2;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="gap-2">
|
||||
<CardHeader>
|
||||
{filePath && (
|
||||
<p className="text-sm font-mono truncate">{filePath}</p>
|
||||
<p className="text-sm font-mono break-all">{filePath}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
@@ -117,6 +126,13 @@ export function AudioAnalysis({
|
||||
<span className="text-muted-foreground">Nyquist:</span>
|
||||
<span className="font-semibold">{(nyquistFreq / 1000).toFixed(1)} kHz</span>
|
||||
</div>
|
||||
{result.file_size > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Size:</span>
|
||||
<span className="font-semibold">{formatFileSize(result.file_size)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dynamic Range - Single line */}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
IsFFmpegInstalled,
|
||||
DownloadFFmpeg,
|
||||
InstallFFmpegFromFile,
|
||||
ConvertAudio,
|
||||
SelectAudioFiles,
|
||||
} from "../../wailsjs/go/main/App";
|
||||
@@ -30,11 +29,20 @@ interface AudioFile {
|
||||
path: string;
|
||||
name: string;
|
||||
format: string;
|
||||
size: number;
|
||||
status: "pending" | "converting" | "success" | "error";
|
||||
error?: string;
|
||||
outputPath?: string;
|
||||
}
|
||||
|
||||
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 BITRATE_OPTIONS = [
|
||||
{ value: "320k", label: "320k" },
|
||||
{ value: "256k", label: "256k" },
|
||||
@@ -42,6 +50,11 @@ const BITRATE_OPTIONS = [
|
||||
{ value: "128k", label: "128k" },
|
||||
];
|
||||
|
||||
const M4A_CODEC_OPTIONS = [
|
||||
{ value: "aac", label: "AAC" },
|
||||
{ value: "alac", label: "ALAC" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "spotiflac_audio_converter_state";
|
||||
|
||||
export function AudioConverterPage() {
|
||||
@@ -90,13 +103,26 @@ export function AudioConverterPage() {
|
||||
}
|
||||
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 [isDragging, setIsDragging] = useState(false);
|
||||
const [isDraggingFFmpeg, setIsDraggingFFmpeg] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// 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 {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (err) {
|
||||
@@ -109,10 +135,10 @@ export function AudioConverterPage() {
|
||||
checkFfmpegInstallation();
|
||||
}, []);
|
||||
|
||||
// Save state to sessionStorage whenever files, outputFormat, or bitrate changes
|
||||
// Save state to sessionStorage whenever files, outputFormat, bitrate, or m4aCodec changes
|
||||
useEffect(() => {
|
||||
saveState({ files, outputFormat, bitrate });
|
||||
}, [files, outputFormat, bitrate, saveState]);
|
||||
saveState({ files, outputFormat, bitrate, m4aCodec });
|
||||
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
|
||||
|
||||
// Auto-set output format to M4A if all files are MP3
|
||||
useEffect(() => {
|
||||
@@ -122,10 +148,19 @@ export function AudioConverterPage() {
|
||||
if (allMP3 && outputFormat !== "m4a") {
|
||||
setOutputFormat("m4a");
|
||||
}
|
||||
}, [files, outputFormat]);
|
||||
|
||||
// Reset to AAC if no FLAC files (ALAC doesn't make sense for lossy input)
|
||||
const hasFlac = files.some((f) => f.format === "flac");
|
||||
if (!hasFlac && m4aCodec === "alac") {
|
||||
setM4aCodec("aac");
|
||||
}
|
||||
}, [files, outputFormat, m4aCodec]);
|
||||
|
||||
// Check if format selection should be disabled (all files are MP3)
|
||||
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
|
||||
|
||||
// Check if any file is FLAC (ALAC only makes sense for lossless input)
|
||||
const hasFlacFiles = files.some((f) => f.format === "flac");
|
||||
|
||||
// Detect fullscreen/maximized window
|
||||
useEffect(() => {
|
||||
@@ -181,61 +216,6 @@ export function AudioConverterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFFmpegFileDrop = useCallback(
|
||||
async (_x: number, _y: number, paths: string[]) => {
|
||||
setIsDraggingFFmpeg(false);
|
||||
|
||||
if (paths.length === 0) return;
|
||||
|
||||
// Only process the first file
|
||||
const filePath = paths[0];
|
||||
const fileName = filePath.split(/[/\\]/).pop()?.toLowerCase() || "";
|
||||
|
||||
// Check if it's likely an ffmpeg executable
|
||||
if (!fileName.includes("ffmpeg")) {
|
||||
toast.error("Invalid File", {
|
||||
description: "Please drop an FFmpeg executable file",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallingFfmpeg(true);
|
||||
try {
|
||||
const result = await InstallFFmpegFromFile(filePath);
|
||||
if (result.success) {
|
||||
toast.success("FFmpeg Installed", {
|
||||
description: "FFmpeg has been installed successfully from file",
|
||||
});
|
||||
setFfmpegInstalled(true);
|
||||
} else {
|
||||
toast.error("Installation Failed", {
|
||||
description: result.error || "Failed to install FFmpeg",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Installation Failed", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setInstallingFfmpeg(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ffmpegInstalled === false) {
|
||||
// Set up drag and drop for FFmpeg installation
|
||||
OnFileDrop((x, y, paths) => {
|
||||
handleFFmpegFileDrop(x, y, paths);
|
||||
}, true);
|
||||
|
||||
return () => {
|
||||
OnFileDropOff();
|
||||
};
|
||||
}
|
||||
}, [ffmpegInstalled, handleFFmpegFileDrop]);
|
||||
|
||||
const handleSelectFiles = async () => {
|
||||
try {
|
||||
const selectedFiles = await SelectAudioFiles();
|
||||
@@ -249,7 +229,7 @@ export function AudioConverterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const addFiles = useCallback((paths: string[]) => {
|
||||
const addFiles = useCallback(async (paths: string[]) => {
|
||||
const validExtensions = [".mp3", ".flac"];
|
||||
|
||||
// Check for M4A files specifically
|
||||
@@ -264,12 +244,19 @@ export function AudioConverterPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// Get file sizes from backend
|
||||
const GetFileSizes = (files: string[]): Promise<Record<string, number>> =>
|
||||
(window as any)["go"]["main"]["App"]["GetFileSizes"](files);
|
||||
|
||||
const validPaths = paths.filter((path) => {
|
||||
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||
return validExtensions.includes(ext);
|
||||
});
|
||||
|
||||
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
|
||||
|
||||
setFiles((prev) => {
|
||||
const newFiles: AudioFile[] = paths
|
||||
.filter((path) => {
|
||||
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
|
||||
return validExtensions.includes(ext);
|
||||
})
|
||||
const newFiles: AudioFile[] = validPaths
|
||||
.filter((path) => !prev.some((f) => f.path === path))
|
||||
.map((path) => {
|
||||
const name = path.split(/[/\\]/).pop() || path;
|
||||
@@ -278,6 +265,7 @@ export function AudioConverterPage() {
|
||||
path,
|
||||
name,
|
||||
format: ext,
|
||||
size: fileSizes[path] || 0,
|
||||
status: "pending" as const,
|
||||
};
|
||||
});
|
||||
@@ -364,6 +352,7 @@ export function AudioConverterPage() {
|
||||
input_files: inputPaths,
|
||||
output_format: outputFormat,
|
||||
bitrate: bitrate,
|
||||
codec: outputFormat === "m4a" ? m4aCodec : "",
|
||||
});
|
||||
|
||||
// Update file statuses based on results
|
||||
@@ -434,35 +423,13 @@ export function AudioConverterPage() {
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${
|
||||
isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"
|
||||
} ${
|
||||
isDraggingFFmpeg
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingFFmpeg(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingFFmpeg(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDraggingFFmpeg(false);
|
||||
}}
|
||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||
} border-muted-foreground/30`}
|
||||
>
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Download className="h-8 w-8 text-muted-foreground" />
|
||||
<Download className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2 text-center">
|
||||
FFmpeg is required to convert audio files.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDraggingFFmpeg
|
||||
? "Drop your FFmpeg executable here"
|
||||
: "Drag and drop your FFmpeg executable here, or click the button below to download automatically."}
|
||||
FFmpeg is required to convert audio files
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleInstallFfmpeg}
|
||||
@@ -538,18 +505,18 @@ export function AudioConverterPage() {
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||
{isDragging
|
||||
? "Drop your audio files here"
|
||||
: "Drag and drop audio files here, or click the button below to select"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-4 text-center">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
<Button onClick={handleSelectFiles} size="lg">
|
||||
<Upload className="h-5 w-5" />
|
||||
Select Files
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground mt-4 text-center">
|
||||
Supported formats: FLAC, MP3
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||
@@ -578,27 +545,54 @@ export function AudioConverterPage() {
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={bitrate}
|
||||
onValueChange={(value) => {
|
||||
if (value) setBitrate(value);
|
||||
}}
|
||||
>
|
||||
{BITRATE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
aria-label={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{/* Codec selection for M4A - only show ALAC option when input has FLAC files */}
|
||||
{outputFormat === "m4a" && hasFlacFiles && (
|
||||
<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">
|
||||
<Label className="whitespace-nowrap">Bitrate:</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={bitrate}
|
||||
onValueChange={(value) => {
|
||||
if (value) setBitrate(value);
|
||||
}}
|
||||
>
|
||||
{BITRATE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
aria-label={option.label}
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -625,6 +619,9 @@ export function AudioConverterPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span className="text-xs uppercase text-muted-foreground">
|
||||
{file.format}
|
||||
</span>
|
||||
|
||||
@@ -9,17 +9,18 @@ interface DownloadProgressProps {
|
||||
}
|
||||
|
||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
return (
|
||||
<div className="w-full space-y-2 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={progress} className="h-2 flex-1" />
|
||||
<Progress value={clampedProgress} className="h-2 flex-1" />
|
||||
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
|
||||
<StopCircle className="h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress}% -{" "}
|
||||
{clampedProgress}% -{" "}
|
||||
{currentTrack
|
||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||
: "Preparing download..."}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<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">
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
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,
|
||||
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);
|
||||
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> =>
|
||||
(window as any)['go']['main']['App']['ReadFileMetadata'](path);
|
||||
const IsFFprobeInstalled = (): Promise<boolean> =>
|
||||
(window as any)['go']['main']['App']['IsFFprobeInstalled']();
|
||||
const DownloadFFmpeg = (): Promise<{ success: boolean; message: string; error?: string }> =>
|
||||
(window as any)['go']['main']['App']['DownloadFFmpeg']();
|
||||
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";
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
children?: FileNode[];
|
||||
expanded?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
interface FileMetadata {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
album_artist: string;
|
||||
track_number: number;
|
||||
disc_number: number;
|
||||
year: string;
|
||||
}
|
||||
|
||||
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}" },
|
||||
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
|
||||
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
||||
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
||||
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
||||
};
|
||||
|
||||
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["custom"].template) : 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);
|
||||
const [showMetadata, setShowMetadata] = useState(false);
|
||||
const [metadataFile, setMetadataFile] = useState<string>("");
|
||||
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
|
||||
const [loadingMetadata, setLoadingMetadata] = useState(false);
|
||||
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
|
||||
const [installingFFprobe, setInstallingFFprobe] = 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);
|
||||
// Handle null/undefined result (can happen on Linux)
|
||||
if (!result || !Array.isArray(result)) {
|
||||
setFiles([]);
|
||||
setSelectedFiles(new Set());
|
||||
return;
|
||||
}
|
||||
// Filter to only show audio files and folders containing audio files
|
||||
const filtered = filterAudioFiles(result as FileNode[]);
|
||||
setFiles(filtered);
|
||||
setSelectedFiles(new Set());
|
||||
} catch (err) {
|
||||
// Don't show error toast for empty directory or no files found
|
||||
const errorMsg = err instanceof Error ? err.message : String(err || "");
|
||||
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
|
||||
toast.error("Failed to load files", {
|
||||
description: errorMsg || "Unknown error",
|
||||
});
|
||||
}
|
||||
setFiles([]);
|
||||
setSelectedFiles(new Set());
|
||||
} 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 toggleFolderSelect = (node: FileNode) => {
|
||||
const folderFiles = getAllAudioFiles([node]);
|
||||
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
|
||||
|
||||
setSelectedFiles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (allSelected) {
|
||||
// Deselect all files in folder
|
||||
folderFiles.forEach((f) => newSet.delete(f.path));
|
||||
} else {
|
||||
// Select all files in folder
|
||||
folderFiles.forEach((f) => newSet.add(f.path));
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
|
||||
const folderFiles = getAllAudioFiles([node]);
|
||||
if (folderFiles.length === 0) return false;
|
||||
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
|
||||
if (selectedCount === 0) return false;
|
||||
if (selectedCount === folderFiles.length) return true;
|
||||
return "indeterminate";
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Check if any selected file is M4A and ffprobe is not installed
|
||||
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
|
||||
if (hasM4A) {
|
||||
const installed = await IsFFprobeInstalled();
|
||||
if (!installed) {
|
||||
setShowFFprobeDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if M4A file needs ffprobe
|
||||
if (filePath.toLowerCase().endsWith(".m4a")) {
|
||||
const installed = await IsFFprobeInstalled();
|
||||
if (!installed) {
|
||||
setShowFFprobeDialog(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setMetadataFile(filePath);
|
||||
setLoadingMetadata(true);
|
||||
try {
|
||||
const metadata = await ReadFileMetadata(filePath);
|
||||
setMetadataInfo(metadata as FileMetadata);
|
||||
setShowMetadata(true);
|
||||
} catch (err) {
|
||||
toast.error("Failed to read metadata", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
setMetadataInfo(null);
|
||||
} finally {
|
||||
setLoadingMetadata(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallFFprobe = async () => {
|
||||
setInstallingFFprobe(true);
|
||||
try {
|
||||
const result = await DownloadFFmpeg();
|
||||
if (result.success) {
|
||||
toast.success("FFprobe installed successfully");
|
||||
setShowFFprobeDialog(false);
|
||||
} else {
|
||||
toast.error("Failed to install FFprobe", {
|
||||
description: result.error || result.message,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Failed to install FFprobe", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setInstallingFFprobe(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 ? (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={isFolderSelected(node) === true}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
(el as HTMLButtonElement).dataset.state =
|
||||
isFolderSelected(node) === "indeterminate" ? "indeterminate" :
|
||||
isFolderSelected(node) ? "checked" : "unchecked";
|
||||
}
|
||||
}}
|
||||
onCheckedChange={() => toggleFolderSelect(node)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"
|
||||
/>
|
||||
{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}
|
||||
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllAudioFiles([node]).length})</span>}
|
||||
</span>
|
||||
{!node.is_dir && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted shrink-0"
|
||||
onClick={(e) => handleShowMetadata(node.path, e)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View Metadata</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</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">
|
||||
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
|
||||
</span>
|
||||
</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 */}
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset to Default?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will reset the rename format to "Title - Artist". Your custom format will be lost.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||
<Button onClick={resetToDefault}>Reset</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Preview Dialog */}
|
||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Preview</DialogTitle>
|
||||
<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 break-all">{item.old_name}</div>
|
||||
{item.error ? (
|
||||
<div className="text-destructive text-xs mt-1">{item.error}</div>
|
||||
) : (
|
||||
<div className="text-primary font-medium break-all 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...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Rename {previewData.filter((p) => !p.error).length} File(s)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Metadata Dialog */}
|
||||
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>File Metadata</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{metadataFile.split(/[/\\]/).pop()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loadingMetadata ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : metadataInfo ? (
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Title</span>
|
||||
<span>{metadataInfo.title || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Artist</span>
|
||||
<span>{metadataInfo.artist || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Album</span>
|
||||
<span>{metadataInfo.album || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Album Artist</span>
|
||||
<span>{metadataInfo.album_artist || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Track</span>
|
||||
<span>{metadataInfo.track_number || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Disc</span>
|
||||
<span>{metadataInfo.disc_number || "-"}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Year</span>
|
||||
<span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No metadata available
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowMetadata(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* FFprobe Install Dialog */}
|
||||
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>FFprobe Required</DialogTitle>
|
||||
<DialogDescription>
|
||||
Reading M4A metadata requires FFprobe. Would you like to download and install it now?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
|
||||
{installingFFprobe ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
"Install FFprobe"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,7 +137,9 @@ export function PlaylistInfo({
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{playlistInfo.owner.display_name}</span>
|
||||
<span>•</span>
|
||||
<span>{playlistInfo.tracks.total} songs</span>
|
||||
<span>
|
||||
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithContext } from "@/components/ui/input-with-context";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Info, XCircle } from "lucide-react";
|
||||
import { CloudDownload, Info, XCircle } from "lucide-react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -75,7 +75,7 @@ export function SearchBar({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4" />
|
||||
<CloudDownload className="h-4 w-4" />
|
||||
Fetch
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { themes, applyTheme } from "@/lib/themes";
|
||||
@@ -44,6 +52,7 @@ export function SettingsPage() {
|
||||
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
|
||||
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
|
||||
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
applyThemeMode(savedSettings.themeMode);
|
||||
@@ -94,6 +103,7 @@ export function SettingsPage() {
|
||||
applyThemeMode(defaultSettings.themeMode);
|
||||
applyTheme(defaultSettings.theme);
|
||||
applyFont(defaultSettings.fontFamily);
|
||||
setShowResetConfirm(false);
|
||||
toast.success("Settings reset to default");
|
||||
};
|
||||
|
||||
@@ -305,37 +315,39 @@ export function SettingsPage() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
value={tempSettings.folderPreset}
|
||||
onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? prev.folderTemplate : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.folderPreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.folderTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
|
||||
placeholder="{artist}/{album}"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={tempSettings.folderPreset}
|
||||
onValueChange={(value: FolderPreset) => {
|
||||
const preset = FOLDER_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
folderPreset: value,
|
||||
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.folderPreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.folderTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))}
|
||||
placeholder="{artist}/{album}"
|
||||
className="h-9 text-sm flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{tempSettings.folderTemplate && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -355,37 +367,39 @@ export function SettingsPage() {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Select
|
||||
value={tempSettings.filenamePreset}
|
||||
onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? prev.filenameTemplate : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.filenamePreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.filenameTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
|
||||
placeholder="{track}. {title}"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={tempSettings.filenamePreset}
|
||||
onValueChange={(value: FilenamePreset) => {
|
||||
const preset = FILENAME_PRESETS[value];
|
||||
setTempSettings(prev => ({
|
||||
...prev,
|
||||
filenamePreset: value,
|
||||
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tempSettings.filenamePreset === "custom" && (
|
||||
<InputWithContext
|
||||
value={tempSettings.filenameTemplate}
|
||||
onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))}
|
||||
placeholder="{track}. {title}"
|
||||
className="h-9 text-sm flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{tempSettings.filenameTemplate && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -394,7 +408,7 @@ export function SettingsPage() {
|
||||
|
||||
{/* Actions */}
|
||||
<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" />
|
||||
Reset to Default
|
||||
</Button>
|
||||
@@ -403,6 +417,22 @@ export function SettingsPage() {
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reset Confirmation Dialog */}
|
||||
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||
<DialogContent className="max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset to Default?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will reset all settings to their default values. Your custom configurations will be lost.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
|
||||
<Button onClick={handleReset}>Reset</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Home, Settings, Bug, Activity, FileMusic, LayoutGrid } from "lucide-react";
|
||||
import { FileMusic, FilePen } from "lucide-react";
|
||||
import { HomeIcon } from "@/components/ui/home";
|
||||
import { SettingsIcon } from "@/components/ui/settings";
|
||||
import { ActivityIcon } from "@/components/ui/activity";
|
||||
import { TerminalIcon } from "@/components/ui/terminal";
|
||||
import { GithubIcon } from "@/components/ui/github";
|
||||
import { BlocksIcon } from "@/components/ui/blocks";
|
||||
import { CoffeeIcon } from "@/components/ui/coffee";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -7,7 +14,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 {
|
||||
currentPage: PageType;
|
||||
@@ -15,57 +22,129 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
const navItems = [
|
||||
{ id: "main" as PageType, icon: Home, label: "Home" },
|
||||
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
|
||||
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
|
||||
{ id: "audio-converter" as PageType, icon: FileMusic, label: "Audio Converter" },
|
||||
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
{navItems.map((item) => (
|
||||
<Tooltip key={item.id} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === item.id ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
{/* Home */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "main" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("main")}
|
||||
>
|
||||
<HomeIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Home</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Settings */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "settings" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("settings")}
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Audio Analysis */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "audio-analysis" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("audio-analysis")}
|
||||
>
|
||||
<ActivityIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Quality Analyzer</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Audio Converter - using lucide icon (no animated version) */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "audio-converter" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("audio-converter")}
|
||||
>
|
||||
<FileMusic className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Audio Converter</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* File Manager - using lucide icon (no animated version) */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "file-manager" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("file-manager")}
|
||||
>
|
||||
<FilePen className="h-5 w-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Debug */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={currentPage === "debug" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onPageChange("debug")}
|
||||
>
|
||||
<TerminalIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Debug Logs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* GitHub - below debug */}
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bug</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Other Projects at bottom */}
|
||||
<div className="mt-auto">
|
||||
{/* Bottom icons */}
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
|
||||
>
|
||||
<GithubIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Report Bug</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -74,13 +153,28 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://exyezed.cc/")}
|
||||
>
|
||||
<LayoutGrid className="h-5 w-5" />
|
||||
<BlocksIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Other Projects</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => openExternal("https://ko-fi.com/afkarxyz")}
|
||||
>
|
||||
<CoffeeIcon size={20} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Support me on Ko-fi</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ActivityIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
pathOffset: 0,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
pathOffset: [1, 0],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"
|
||||
variants={PATH_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ActivityIcon.displayName = 'ActivityIcon';
|
||||
|
||||
export { ActivityIcon };
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BlocksIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const VARIANTS: Variants = {
|
||||
normal: { translateX: 0, translateY: 0 },
|
||||
animate: { translateX: -4, translateY: 4 },
|
||||
};
|
||||
|
||||
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" />
|
||||
<motion.path
|
||||
d="M14 3h7v7h-7z"
|
||||
variants={VARIANTS}
|
||||
animate={controls}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
BlocksIcon.displayName = 'BlocksIcon';
|
||||
|
||||
export { BlocksIcon };
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CoffeeIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
animate: (custom: number) => ({
|
||||
y: -3,
|
||||
opacity: [0, 1, 0],
|
||||
transition: {
|
||||
repeat: Infinity,
|
||||
duration: 1.5,
|
||||
ease: 'easeInOut',
|
||||
delay: 0.2 * custom,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const CoffeeIcon = forwardRef<CoffeeIconHandle, CoffeeIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<motion.path
|
||||
d="M10 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0.2}
|
||||
/>
|
||||
<motion.path
|
||||
d="M14 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0.4}
|
||||
/>
|
||||
<motion.path
|
||||
d="M6 2v2"
|
||||
animate={controls}
|
||||
variants={PATH_VARIANTS}
|
||||
custom={0}
|
||||
/>
|
||||
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CoffeeIcon.displayName = 'CoffeeIcon';
|
||||
|
||||
export { CoffeeIcon };
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface GithubIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const BODY_VARIANTS: Variants = {
|
||||
normal: {
|
||||
opacity: 1,
|
||||
pathLength: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
pathLength: [0, 1],
|
||||
scale: [0.9, 1],
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TAIL_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
draw: {
|
||||
pathLength: [0, 1],
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
},
|
||||
},
|
||||
wag: {
|
||||
pathLength: 1,
|
||||
rotate: [0, -15, 15, -10, 10, -5, 5],
|
||||
transition: {
|
||||
duration: 2.5,
|
||||
ease: 'easeInOut',
|
||||
repeat: Infinity,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GithubIcon = forwardRef<GithubIconHandle, GithubIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const bodyControls = useAnimation();
|
||||
const tailControls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: async () => {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
},
|
||||
stopAnimation: () => {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
async (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('animate');
|
||||
await tailControls.start('draw');
|
||||
tailControls.start('wag');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[bodyControls, onMouseEnter, tailControls]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
bodyControls.start('normal');
|
||||
tailControls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[bodyControls, tailControls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<motion.path
|
||||
variants={BODY_VARIANTS}
|
||||
initial="normal"
|
||||
animate={bodyControls}
|
||||
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
|
||||
/>
|
||||
<motion.path
|
||||
variants={TAIL_VARIANTS}
|
||||
initial="normal"
|
||||
animate={tailControls}
|
||||
d="M9 18c-4.51 2-5-2-7-2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GithubIcon.displayName = 'GithubIcon';
|
||||
|
||||
export { GithubIcon };
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import type { Transition, Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface HomeIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TRANSITION: Transition = {
|
||||
duration: 0.6,
|
||||
opacity: { duration: 0.2 },
|
||||
};
|
||||
|
||||
const PATH_VARIANTS: Variants = {
|
||||
normal: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
animate: {
|
||||
opacity: [0, 1],
|
||||
pathLength: [0, 1],
|
||||
},
|
||||
};
|
||||
|
||||
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<motion.path
|
||||
d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"
|
||||
variants={PATH_VARIANTS}
|
||||
transition={DEFAULT_TRANSITION}
|
||||
animate={controls}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
HomeIcon.displayName = 'HomeIcon';
|
||||
|
||||
export { HomeIcon };
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SettingsIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
transition={{ type: 'spring', stiffness: 50, damping: 10 }}
|
||||
variants={{
|
||||
normal: {
|
||||
rotate: 0,
|
||||
},
|
||||
animate: {
|
||||
rotate: 180,
|
||||
},
|
||||
}}
|
||||
animate={controls}
|
||||
>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</motion.svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SettingsIcon.displayName = 'SettingsIcon';
|
||||
|
||||
export { SettingsIcon };
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import type { Variants } from 'motion/react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TerminalIconHandle {
|
||||
startAnimation: () => void;
|
||||
stopAnimation: () => void;
|
||||
}
|
||||
|
||||
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const LINE_VARIANTS: Variants = {
|
||||
normal: { opacity: 1 },
|
||||
animate: {
|
||||
opacity: [1, 0, 1],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(
|
||||
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
|
||||
const controls = useAnimation();
|
||||
const isControlledRef = useRef(false);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
isControlledRef.current = true;
|
||||
|
||||
return {
|
||||
startAnimation: () => controls.start('animate'),
|
||||
stopAnimation: () => controls.start('normal'),
|
||||
};
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('animate');
|
||||
} else {
|
||||
onMouseEnter?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseEnter]
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isControlledRef.current) {
|
||||
controls.start('normal');
|
||||
} else {
|
||||
onMouseLeave?.(e);
|
||||
}
|
||||
},
|
||||
[controls, onMouseLeave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<motion.line
|
||||
x1="12"
|
||||
x2="20"
|
||||
y1="19"
|
||||
y2="19"
|
||||
variants={LINE_VARIANTS}
|
||||
animate={controls}
|
||||
initial="normal"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TerminalIcon.displayName = 'TerminalIcon';
|
||||
|
||||
export { TerminalIcon };
|
||||
@@ -6,6 +6,27 @@ import { joinPath, sanitizePath } from "@/lib/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
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() {
|
||||
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
@@ -53,6 +74,7 @@ export function useDownload() {
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
year: releaseYear,
|
||||
@@ -296,12 +318,12 @@ export function useDownload() {
|
||||
|
||||
let outputDir = settings.downloadPath;
|
||||
let useAlbumTrackNumber = false;
|
||||
|
||||
// Replace forward slashes in template data values to prevent them from being interpreted as path separators
|
||||
const placeholder = "__SLASH_PLACEHOLDER__";
|
||||
const templateData: TemplateData = {
|
||||
artist: artistName?.replace(/\//g, placeholder),
|
||||
album: albumName?.replace(/\//g, placeholder),
|
||||
album_artist: albumArtist?.replace(/\//g, placeholder) || artistName?.replace(/\//g, placeholder),
|
||||
title: trackName?.replace(/\//g, placeholder),
|
||||
track: position,
|
||||
year: releaseYear,
|
||||
@@ -597,7 +619,40 @@ export function useDownload() {
|
||||
setBulkDownloadType("selected");
|
||||
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 itemIDs: string[] = [];
|
||||
for (const isrc of selectedTracks) {
|
||||
@@ -609,65 +664,78 @@ export function useDownload() {
|
||||
track?.album_name || ""
|
||||
);
|
||||
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 errorCount = 0;
|
||||
let skippedCount = 0;
|
||||
let skippedCount = existingISRCs.size;
|
||||
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) {
|
||||
toast.info(
|
||||
`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`
|
||||
`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const isrc = selectedTracks[i];
|
||||
const track = allTracks.find((t) => t.isrc === isrc);
|
||||
const itemID = itemIDs[i];
|
||||
const track = tracksToDownload[i];
|
||||
const isrc = track.isrc;
|
||||
// Find original index and itemID
|
||||
const originalIndex = selectedTracks.indexOf(isrc);
|
||||
const itemID = itemIDs[originalIndex];
|
||||
|
||||
setDownloadingTrack(isrc);
|
||||
|
||||
if (track) {
|
||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||
}
|
||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||
|
||||
try {
|
||||
// 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
|
||||
const response = await downloadWithItemID(
|
||||
isrc,
|
||||
settings,
|
||||
itemID,
|
||||
track?.name,
|
||||
track?.artists,
|
||||
track?.album_name,
|
||||
track.name,
|
||||
track.artists,
|
||||
track.album_name,
|
||||
folderName,
|
||||
i + 1, // Sequential position based on selection order
|
||||
track?.spotify_id,
|
||||
track?.duration_ms,
|
||||
originalIndex + 1, // Sequential position based on selection order
|
||||
track.spotify_id,
|
||||
track.duration_ms,
|
||||
isAlbum,
|
||||
releaseYear,
|
||||
track?.album_artist || "", // Use album_artist from Spotify metadata
|
||||
track?.release_date,
|
||||
track?.images, // Spotify cover URL
|
||||
track?.track_number, // Spotify album track number
|
||||
track?.disc_number, // Spotify disc number
|
||||
track?.total_tracks // Total tracks in album
|
||||
track.album_artist || "", // Use album_artist from Spotify metadata
|
||||
track.release_date,
|
||||
track.images, // Spotify cover URL
|
||||
track.track_number, // Spotify album track number
|
||||
track.disc_number, // Spotify disc number
|
||||
track.total_tracks // Total tracks in album
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
if (response.already_exists) {
|
||||
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));
|
||||
} else {
|
||||
successCount++;
|
||||
logger.success(`downloaded: ${track?.name} - ${track?.artists}`);
|
||||
logger.success(`downloaded: ${track.name} - ${track.artists}`);
|
||||
}
|
||||
setDownloadedTracks((prev) => new Set(prev).add(isrc));
|
||||
setFailedTracks((prev) => {
|
||||
@@ -677,19 +745,20 @@ export function useDownload() {
|
||||
});
|
||||
} else {
|
||||
errorCount++;
|
||||
logger.error(`failed: ${track?.name} - ${track?.artists}`);
|
||||
logger.error(`failed: ${track.name} - ${track.artists}`);
|
||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||
}
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
logger.error(`error: ${track?.name} - ${err}`);
|
||||
logger.error(`error: ${track.name} - ${err}`);
|
||||
setFailedTracks((prev) => new Set(prev).add(isrc));
|
||||
// Mark item as failed in queue
|
||||
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
|
||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||
const completedCount = skippedCount + successCount + errorCount;
|
||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||
}
|
||||
|
||||
setDownloadingTrack(null);
|
||||
@@ -740,7 +809,35 @@ export function useDownload() {
|
||||
setBulkDownloadType("all");
|
||||
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 itemIDs: string[] = [];
|
||||
for (const track of tracksWithIsrc) {
|
||||
@@ -751,23 +848,39 @@ export function useDownload() {
|
||||
track.album_name || ""
|
||||
);
|
||||
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 errorCount = 0;
|
||||
let skippedCount = 0;
|
||||
let skippedCount = existingISRCs.size;
|
||||
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) {
|
||||
toast.info(
|
||||
`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`
|
||||
`Download stopped. ${successCount} tracks downloaded, ${tracksToDownload.length - i} remaining.`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const track = tracksWithIsrc[i];
|
||||
const itemID = itemIDs[i];
|
||||
const track = tracksToDownload[i];
|
||||
// Find original index and itemID
|
||||
const originalIndex = tracksWithIsrc.findIndex((t) => t.isrc === track.isrc);
|
||||
const itemID = itemIDs[originalIndex];
|
||||
|
||||
setDownloadingTrack(track.isrc);
|
||||
setCurrentDownloadInfo({ name: track.name, artists: track.artists });
|
||||
@@ -784,7 +897,7 @@ export function useDownload() {
|
||||
track.artists,
|
||||
track.album_name,
|
||||
folderName,
|
||||
i + 1,
|
||||
originalIndex + 1,
|
||||
track.spotify_id,
|
||||
track.duration_ms,
|
||||
isAlbum,
|
||||
@@ -826,7 +939,8 @@ export function useDownload() {
|
||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
setDownloadProgress(Math.round(((i + 1) / total) * 100));
|
||||
const completedCount = skippedCount + successCount + errorCount;
|
||||
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
// Folder structure presets
|
||||
export type FolderPreset = "none" | "artist" | "album" | "artist-album" | "artist-year-album" | "custom";
|
||||
export type FolderPreset = "none" | "artist" | "album" | "year-album" | "year-artist-album" | "artist-album" | "artist-year-album" | "artist-year-nested-album" | "album-artist" | "album-artist-album" | "album-artist-year-album" | "album-artist-year-nested-album" | "year" | "year-artist" | "custom";
|
||||
|
||||
// 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" | "artist-album-title" | "track-dash-title" | "disc-track-title" | "disc-track-title-artist" | "custom";
|
||||
|
||||
export interface Settings {
|
||||
downloadPath: string;
|
||||
@@ -38,9 +38,18 @@ export const FOLDER_PRESETS: Record<FolderPreset, { label: string; template: str
|
||||
"none": { label: "No Subfolder", template: "" },
|
||||
"artist": { label: "Artist", template: "{artist}" },
|
||||
"album": { label: "Album", template: "{album}" },
|
||||
"year-album": { label: "[Year] Album", template: "[{year}] {album}" },
|
||||
"year-artist-album": { label: "[Year] Artist - Album", template: "[{year}] {artist} - {album}" },
|
||||
"artist-album": { label: "Artist / Album", template: "{artist}/{album}" },
|
||||
"artist-year-album": { label: "Artist / [Year] Album", template: "{artist}/[{year}] {album}" },
|
||||
"custom": { label: "Custom...", template: "" },
|
||||
"artist-year-nested-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}" },
|
||||
"album-artist-year-nested-album": { label: "Album Artist / Year / Album", template: "{album_artist}/{year}/{album}" },
|
||||
"year": { label: "Year", template: "{year}" },
|
||||
"year-artist": { label: "Year / Artist", template: "{year}/{artist}" },
|
||||
"custom": { label: "Custom...", template: "{artist}/{album}" },
|
||||
};
|
||||
|
||||
// Filename preset templates
|
||||
@@ -51,18 +60,24 @@ export const FILENAME_PRESETS: Record<FilenamePreset, { label: string; template:
|
||||
"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}" },
|
||||
"custom": { label: "Custom...", template: "" },
|
||||
"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}" },
|
||||
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
|
||||
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
|
||||
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
|
||||
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
|
||||
"custom": { label: "Custom...", template: "{title} - {artist}" },
|
||||
};
|
||||
|
||||
// Available 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: "{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: "{disc}", description: "Disc number", example: "1" },
|
||||
{ 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
|
||||
@@ -194,8 +209,10 @@ export function getSettings(): Settings {
|
||||
export interface TemplateData {
|
||||
artist?: string;
|
||||
album?: string;
|
||||
album_artist?: string;
|
||||
title?: string;
|
||||
track?: number;
|
||||
disc?: number;
|
||||
year?: string;
|
||||
isrc?: string;
|
||||
playlist?: string;
|
||||
@@ -207,10 +224,12 @@ export function parseTemplate(template: string, data: TemplateData): string {
|
||||
let result = template;
|
||||
|
||||
// Replace each variable
|
||||
result = result.replace(/\{title\}/g, data.title || "Unknown Title");
|
||||
result = result.replace(/\{artist\}/g, data.artist || "Unknown Artist");
|
||||
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(/\{disc\}/g, data.disc ? String(data.disc) : "1");
|
||||
result = result.replace(/\{year\}/g, data.year || "0000");
|
||||
result = result.replace(/\{isrc\}/g, data.isrc || "");
|
||||
result = result.replace(/\{playlist\}/g, data.playlist || "");
|
||||
|
||||
@@ -168,6 +168,7 @@ export interface SpectrumData {
|
||||
|
||||
export interface AnalysisResult {
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
bits_per_sample: number;
|
||||
@@ -228,3 +229,13 @@ export interface CoverDownloadResponse {
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface AudioMetadata {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
album_artist: string;
|
||||
track_number: number;
|
||||
disc_number: number;
|
||||
year: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module spotiflac
|
||||
|
||||
go 1.25.4
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/bogem/id3v2/v2 v2.1.4
|
||||
|
||||
@@ -6,7 +6,5 @@
|
||||
"wolf.qqdl.site",
|
||||
"tidal.kinoplus.online",
|
||||
"tidal-api.binimum.org",
|
||||
"tidal-api-2.binimum.org",
|
||||
"dev-api.squid.wtf",
|
||||
"triton.squid.wtf"
|
||||
]
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"info": {
|
||||
"productName": "SpotiFLAC",
|
||||
"productVersion": "6.8"
|
||||
"productVersion": "6.9"
|
||||
},
|
||||
"wailsjsdir": "./frontend",
|
||||
"assetdir": "./frontend/dist",
|
||||
|
||||
Reference in New Issue
Block a user