diff --git a/app.go b/app.go index e3b355c..189e659 100644 --- a/app.go +++ b/app.go @@ -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() diff --git a/backend/ffmpeg.go b/backend/ffmpeg.go index 858c2bf..19fdf06 100644 --- a/backend/ffmpeg.go +++ b/backend/ffmpeg.go @@ -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() @@ -173,7 +207,7 @@ func DownloadFFmpeg(progressCallback func(int)) error { } } -// extractZip extracts ffmpeg from a zip archive +// extractZip extracts ffmpeg and ffprobe from a zip archive func extractZip(zipPath, destDir string) error { r, err := zip.OpenReader(zipPath) if err != nil { @@ -182,44 +216,68 @@ 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 { + 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") + if !foundFFmpeg { + return fmt.Errorf("ffmpeg executable not found in archive") + } + + if !foundFFprobe { + fmt.Printf("[FFmpeg] Warning: ffprobe not found in archive\n") + } + + return nil } -// extractTarXz extracts ffmpeg from a tar.xz archive +// extractTarXz extracts ffmpeg and ffprobe from a tar.xz archive func extractTarXz(tarXzPath, destDir string) error { file, err := os.Open(tarXzPath) if err != nil { @@ -235,7 +293,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,27 +306,49 @@ 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 { + 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") + if !foundFFmpeg { + return fmt.Errorf("ffmpeg executable not found in archive") + } + + if !foundFFprobe { + fmt.Printf("[FFmpeg] Warning: ffprobe not found in archive\n") + } + + return nil } // ConvertAudioRequest represents a request to convert audio files diff --git a/backend/filemanager.go b/backend/filemanager.go index be1a754..8955a05 100644 --- a/backend/filemanager.go +++ b/backend/filemanager.go @@ -1,8 +1,10 @@ package backend import ( + "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -30,7 +32,6 @@ type AudioMetadata struct { TrackNumber int `json:"track_number"` DiscNumber int `json:"disc_number"` Year string `json:"year"` - ISRC string `json:"isrc"` } // RenamePreview represents a preview of file rename operation @@ -183,8 +184,6 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) { } case "DATE", "YEAR": metadata.Year = value - case "ISRC": - metadata.ISRC = value } } } @@ -218,7 +217,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) { // Get Track Number if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 { if textFrame, ok := frames[0].(id3v2.TextFrame); ok { - // Format might be "4" or "4/12" trackStr := strings.Split(textFrame.Text, "/")[0] if num, err := strconv.Atoi(trackStr); err == nil { metadata.TrackNumber = num @@ -236,21 +234,103 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) { } } - // Get ISRC (TSRC) - if frames := tag.GetFrames("TSRC"); len(frames) > 0 { - if textFrame, ok := frames[0].(id3v2.TextFrame); ok { - metadata.ISRC = textFrame.Text + return metadata, nil +} + +// 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 -func readM4aMetadata(_ string) (*AudioMetadata, error) { - // For M4A, we'll use a simpler approach - just return empty metadata - // Full M4A metadata reading would require additional libraries - return &AudioMetadata{}, nil +// 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 diff --git a/frontend/src/components/AudioConverterPage.tsx b/frontend/src/components/AudioConverterPage.tsx index b42020e..32b122a 100644 --- a/frontend/src/components/AudioConverterPage.tsx +++ b/frontend/src/components/AudioConverterPage.tsx @@ -584,18 +584,18 @@ export function AudioConverterPage() {
+
{isDragging ? "Drop your audio files here" : "Drag and drop audio files here, or click the button below to select"}
-- Supported formats: FLAC, MP3 -
++ Supported formats: FLAC, MP3 +
> ) : (
- {progress}% -{" "}
+ {clampedProgress}% -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
diff --git a/frontend/src/components/FileManagerPage.tsx b/frontend/src/components/FileManagerPage.tsx
index 892e96e..f4b95e8 100644
--- a/frontend/src/components/FileManagerPage.tsx
+++ b/frontend/src/components/FileManagerPage.tsx
@@ -36,6 +36,12 @@ const PreviewRenameFiles = (files: string[], format: string): Promise
@@ -452,12 +589,12 @@ export function FileManagerPage() {