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 +

) : (
diff --git a/frontend/src/components/DownloadProgress.tsx b/frontend/src/components/DownloadProgress.tsx index a75ae9e..57ea8ba 100644 --- a/frontend/src/components/DownloadProgress.tsx +++ b/frontend/src/components/DownloadProgress.tsx @@ -9,17 +9,18 @@ interface DownloadProgressProps { } export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) { + const clampedProgress = Math.min(100, Math.max(0, progress)); return (
- +

- {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 => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format); +const ReadFileMetadata = (path: string): Promise => + (window as any)['go']['main']['App']['ReadFileMetadata'](path); +const IsFFprobeInstalled = (): Promise => + (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 { @@ -67,6 +73,16 @@ interface FileNode { 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 = { "title": { label: "Title", template: "{title}" }, "title-artist": { label: "Title - Artist", template: "{title} - {artist}" }, @@ -139,6 +155,12 @@ export function FileManagerPage() { const [previewOnly, setPreviewOnly] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showMetadata, setShowMetadata] = useState(false); + const [metadataFile, setMetadataFile] = useState(""); + const [metadataInfo, setMetadataInfo] = useState(null); + const [loadingMetadata, setLoadingMetadata] = useState(false); + const [showFFprobeDialog, setShowFFprobeDialog] = useState(false); + const [installingFFprobe, setInstallingFFprobe] = useState(false); // Save state to sessionStorage useEffect(() => { @@ -177,9 +199,13 @@ export function FileManagerPage() { setFiles(filtered); setSelectedFiles(new Set()); } catch (err) { - toast.error("Failed to load files", { - description: err instanceof Error ? err.message : "Unknown error", - }); + // Don't show error toast for empty directory or no files found + const errorMsg = err instanceof Error ? err.message : ""; + if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) { + toast.error("Failed to load files", { + description: errorMsg || "Unknown error", + }); + } } finally { setLoading(false); } @@ -253,6 +279,32 @@ export function FileManagerPage() { }); }; + 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))); @@ -287,7 +339,16 @@ export function FileManagerPage() { return; } - setLoading(true); + // 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); @@ -297,8 +358,55 @@ export function FileManagerPage() { 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 { - setLoading(false); + 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); } }; @@ -345,6 +453,19 @@ export function FileManagerPage() { > {node.is_dir ? ( <> + { + 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 ? ( ) : ( @@ -363,9 +484,25 @@ export function FileManagerPage() { )} - {node.name} + + {node.name} + {node.is_dir && ({getAllAudioFiles([node]).length})} + {!node.is_dir && ( - {formatFileSize(node.size)} + <> + {formatFileSize(node.size)} + + + + + View Metadata + + )}

{node.is_dir && node.expanded && node.children && ( @@ -440,7 +577,7 @@ export function FileManagerPage() { - Reset to default + Reset to Default

@@ -452,12 +589,12 @@ export function FileManagerPage() {

- - {selectedFiles.size} of {allAudioFiles.length} file(s) selected - + + {selectedFiles.size} of {allAudioFiles.length} file(s) selected +
@@ -577,6 +714,100 @@ export function FileManagerPage() { + + {/* Metadata Dialog */} + + + +
+ File Metadata + +
+ + {metadataFile.split(/[/\\]/).pop()} + +
+ + {loadingMetadata ? ( +
+ +
+ ) : metadataInfo ? ( +
+
+ Title + {metadataInfo.title || "-"} +
+
+ Artist + {metadataInfo.artist || "-"} +
+
+ Album + {metadataInfo.album || "-"} +
+
+ Album Artist + {metadataInfo.album_artist || "-"} +
+
+ Track + {metadataInfo.track_number || "-"} +
+
+ Disc + {metadataInfo.disc_number || "-"} +
+
+ Year + {metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"} +
+
+ ) : ( +
+ No metadata available +
+ )} + + + + +
+
+ + {/* FFprobe Install Dialog */} + + + + FFprobe Required + + Reading M4A metadata requires FFprobe. Would you like to download and install it now? + + + + + + + +
); } diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts index aba0ed9..c7a0672 100644 --- a/frontend/src/hooks/useDownload.ts +++ b/frontend/src/hooks/useDownload.ts @@ -757,7 +757,8 @@ export function useDownload() { await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); } - setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100)); + const completedCount = skippedCount + successCount + errorCount; + setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100))); } setDownloadingTrack(null); @@ -938,7 +939,8 @@ export function useDownload() { await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err)); } - setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100)); + const completedCount = skippedCount + successCount + errorCount; + setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100))); } setDownloadingTrack(null); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 5d81041..730d1f3 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -229,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; +}