v6.9
This commit is contained in:
@@ -608,6 +608,11 @@ func (a *App) IsFFmpegInstalled() (bool, error) {
|
|||||||
return backend.IsFFmpegInstalled()
|
return backend.IsFFmpegInstalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsFFprobeInstalled checks if ffprobe is installed
|
||||||
|
func (a *App) IsFFprobeInstalled() (bool, error) {
|
||||||
|
return backend.IsFFprobeInstalled()
|
||||||
|
}
|
||||||
|
|
||||||
// GetFFmpegPath returns the path to ffmpeg
|
// GetFFmpegPath returns the path to ffmpeg
|
||||||
func (a *App) GetFFmpegPath() (string, error) {
|
func (a *App) GetFFmpegPath() (string, error) {
|
||||||
return backend.GetFFmpegPath()
|
return backend.GetFFmpegPath()
|
||||||
|
|||||||
+96
-14
@@ -57,6 +57,40 @@ func GetFFmpegPath() (string, error) {
|
|||||||
return filepath.Join(ffmpegDir, ffmpegName), nil
|
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
|
// IsFFmpegInstalled checks if ffmpeg is installed in the app directory
|
||||||
func IsFFmpegInstalled() (bool, error) {
|
func IsFFmpegInstalled() (bool, error) {
|
||||||
ffmpegPath, err := GetFFmpegPath()
|
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 {
|
func extractZip(zipPath, destDir string) error {
|
||||||
r, err := zip.OpenReader(zipPath)
|
r, err := zip.OpenReader(zipPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,44 +216,68 @@ func extractZip(zipPath, destDir string) error {
|
|||||||
defer r.Close()
|
defer r.Close()
|
||||||
|
|
||||||
ffmpegName := "ffmpeg"
|
ffmpegName := "ffmpeg"
|
||||||
|
ffprobeName := "ffprobe"
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
ffmpegName = "ffmpeg.exe"
|
ffmpegName = "ffmpeg.exe"
|
||||||
|
ffprobeName = "ffprobe.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
destPath := filepath.Join(destDir, ffmpegName)
|
foundFFmpeg := false
|
||||||
|
foundFFprobe := false
|
||||||
|
|
||||||
for _, f := range r.File {
|
for _, f := range r.File {
|
||||||
// Look for ffmpeg executable in any subdirectory
|
|
||||||
baseName := filepath.Base(f.Name)
|
baseName := filepath.Base(f.Name)
|
||||||
if baseName == ffmpegName && !f.FileInfo().IsDir() {
|
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)
|
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
|
||||||
|
|
||||||
rc, err := f.Open()
|
rc, err := f.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open file in zip: %w", err)
|
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)
|
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
rc.Close()
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(outFile, rc)
|
_, err = io.Copy(outFile, rc)
|
||||||
|
rc.Close()
|
||||||
|
outFile.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to extract file: %w", err)
|
return fmt.Errorf("failed to extract file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !foundFFmpeg {
|
||||||
return fmt.Errorf("ffmpeg executable not found in archive")
|
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 {
|
func extractTarXz(tarXzPath, destDir string) error {
|
||||||
file, err := os.Open(tarXzPath)
|
file, err := os.Open(tarXzPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -235,7 +293,9 @@ func extractTarXz(tarXzPath, destDir string) error {
|
|||||||
tarReader := tar.NewReader(xzReader)
|
tarReader := tar.NewReader(xzReader)
|
||||||
|
|
||||||
ffmpegName := "ffmpeg"
|
ffmpegName := "ffmpeg"
|
||||||
destPath := filepath.Join(destDir, ffmpegName)
|
ffprobeName := "ffprobe"
|
||||||
|
foundFFmpeg := false
|
||||||
|
foundFFprobe := false
|
||||||
|
|
||||||
for {
|
for {
|
||||||
header, err := tarReader.Next()
|
header, err := tarReader.Next()
|
||||||
@@ -246,27 +306,49 @@ func extractTarXz(tarXzPath, destDir string) error {
|
|||||||
return fmt.Errorf("failed to read tar: %w", err)
|
return fmt.Errorf("failed to read tar: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if header.Typeflag != tar.TypeReg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
baseName := filepath.Base(header.Name)
|
baseName := filepath.Base(header.Name)
|
||||||
if baseName == ffmpegName && header.Typeflag == tar.TypeReg {
|
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)
|
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
|
||||||
|
|
||||||
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(outFile, tarReader)
|
_, err = io.Copy(outFile, tarReader)
|
||||||
|
outFile.Close()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to extract file: %w", err)
|
return fmt.Errorf("failed to extract file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !foundFFmpeg {
|
||||||
return fmt.Errorf("ffmpeg executable not found in archive")
|
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
|
// ConvertAudioRequest represents a request to convert audio files
|
||||||
|
|||||||
+92
-12
@@ -1,8 +1,10 @@
|
|||||||
package backend
|
package backend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -30,7 +32,6 @@ type AudioMetadata struct {
|
|||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
Year string `json:"year"`
|
Year string `json:"year"`
|
||||||
ISRC string `json:"isrc"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenamePreview represents a preview of file rename operation
|
// RenamePreview represents a preview of file rename operation
|
||||||
@@ -183,8 +184,6 @@ func readFlacMetadata(filePath string) (*AudioMetadata, error) {
|
|||||||
}
|
}
|
||||||
case "DATE", "YEAR":
|
case "DATE", "YEAR":
|
||||||
metadata.Year = value
|
metadata.Year = value
|
||||||
case "ISRC":
|
|
||||||
metadata.ISRC = value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,7 +217,6 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
|||||||
// Get Track Number
|
// Get Track Number
|
||||||
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
|
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
|
||||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
||||||
// Format might be "4" or "4/12"
|
|
||||||
trackStr := strings.Split(textFrame.Text, "/")[0]
|
trackStr := strings.Split(textFrame.Text, "/")[0]
|
||||||
if num, err := strconv.Atoi(trackStr); err == nil {
|
if num, err := strconv.Atoi(trackStr); err == nil {
|
||||||
metadata.TrackNumber = num
|
metadata.TrackNumber = num
|
||||||
@@ -236,21 +234,103 @@ func readMp3Metadata(filePath string) (*AudioMetadata, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get ISRC (TSRC)
|
return metadata, nil
|
||||||
if frames := tag.GetFrames("TSRC"); len(frames) > 0 {
|
}
|
||||||
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
|
|
||||||
metadata.ISRC = textFrame.Text
|
// 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
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readM4aMetadata reads metadata from an M4A file
|
// readM4aMetadata reads metadata from an M4A file using ffprobe
|
||||||
func readM4aMetadata(_ string) (*AudioMetadata, error) {
|
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
|
||||||
// For M4A, we'll use a simpler approach - just return empty metadata
|
metadata, err := readMetadataWithFFprobe(filePath)
|
||||||
// Full M4A metadata reading would require additional libraries
|
if err != nil {
|
||||||
return &AudioMetadata{}, nil
|
return &AudioMetadata{}, nil
|
||||||
|
}
|
||||||
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateFilename generates a new filename based on metadata and format template
|
// GenerateFilename generates a new filename based on metadata and format template
|
||||||
|
|||||||
@@ -584,18 +584,18 @@ export function AudioConverterPage() {
|
|||||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
<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" />
|
<Upload className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-2 text-center">
|
<p className="text-sm text-muted-foreground mb-4 text-center">
|
||||||
{isDragging
|
{isDragging
|
||||||
? "Drop your audio files here"
|
? "Drop your audio files here"
|
||||||
: "Drag and drop audio files here, or click the button below to select"}
|
: "Drag and drop audio files here, or click the button below to select"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mb-4 text-center">
|
|
||||||
Supported formats: FLAC, MP3
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleSelectFiles} size="lg">
|
<Button onClick={handleSelectFiles} size="lg">
|
||||||
<Upload className="h-5 w-5" />
|
<Upload className="h-5 w-5" />
|
||||||
Select Files
|
Select Files
|
||||||
</Button>
|
</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">
|
<div className="w-full h-full p-6 space-y-4 flex flex-col">
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ interface DownloadProgressProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
|
||||||
|
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-2 mt-4">
|
<div className="w-full space-y-2 mt-4">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
|
||||||
<StopCircle className="h-4 w-4" />
|
<StopCircle className="h-4 w-4" />
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{progress}% -{" "}
|
{clampedProgress}% -{" "}
|
||||||
{currentTrack
|
{currentTrack
|
||||||
? `${currentTrack.name} - ${currentTrack.artists}`
|
? `${currentTrack.name} - ${currentTrack.artists}`
|
||||||
: "Preparing download..."}
|
: "Preparing download..."}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const PreviewRenameFiles = (files: string[], format: string): Promise<backend.Re
|
|||||||
(window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
|
(window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
|
||||||
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> =>
|
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> =>
|
||||||
(window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
|
(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 { toastWithSound as toast } from "@/lib/toast-with-sound";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +73,16 @@ interface FileNode {
|
|||||||
selected?: 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 }> = {
|
const FORMAT_PRESETS: Record<string, { label: string; template: string }> = {
|
||||||
"title": { label: "Title", template: "{title}" },
|
"title": { label: "Title", template: "{title}" },
|
||||||
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
|
||||||
@@ -139,6 +155,12 @@ export function FileManagerPage() {
|
|||||||
const [previewOnly, setPreviewOnly] = useState(false);
|
const [previewOnly, setPreviewOnly] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [showResetConfirm, setShowResetConfirm] = 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
|
// Save state to sessionStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -177,9 +199,13 @@ export function FileManagerPage() {
|
|||||||
setFiles(filtered);
|
setFiles(filtered);
|
||||||
setSelectedFiles(new Set());
|
setSelectedFiles(new Set());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 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", {
|
toast.error("Failed to load files", {
|
||||||
description: err instanceof Error ? err.message : "Unknown error",
|
description: errorMsg || "Unknown error",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 selectAll = () => {
|
||||||
const allAudioFiles = getAllAudioFiles(files);
|
const allAudioFiles = getAllAudioFiles(files);
|
||||||
setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
|
setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
|
||||||
@@ -287,7 +339,16 @@ export function FileManagerPage() {
|
|||||||
return;
|
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 {
|
try {
|
||||||
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
|
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
|
||||||
setPreviewData(result);
|
setPreviewData(result);
|
||||||
@@ -297,8 +358,55 @@ export function FileManagerPage() {
|
|||||||
toast.error("Failed to generate preview", {
|
toast.error("Failed to generate preview", {
|
||||||
description: err instanceof Error ? err.message : "Unknown error",
|
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 {
|
} 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 ? (
|
{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 ? (
|
{node.expanded ? (
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -363,9 +484,25 @@ export function FileManagerPage() {
|
|||||||
<FileMusic className="h-4 w-4 text-primary shrink-0" />
|
<FileMusic className="h-4 w-4 text-primary shrink-0" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="truncate text-sm flex-1">{node.name}</span>
|
<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 && (
|
{!node.is_dir && (
|
||||||
|
<>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
|
<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>
|
</div>
|
||||||
{node.is_dir && node.expanded && node.children && (
|
{node.is_dir && node.expanded && node.children && (
|
||||||
@@ -440,7 +577,7 @@ export function FileManagerPage() {
|
|||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Reset to default</TooltipContent>
|
<TooltipContent>Reset to Default</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -452,12 +589,12 @@ export function FileManagerPage() {
|
|||||||
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
|
<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 justify-between p-3 border-b bg-muted/30 shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
|
||||||
{allSelected ? "Deselect All" : "Select All"}
|
{allSelected ? "Deselect All" : "Select All"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -538,11 +675,11 @@ export function FileManagerPage() {
|
|||||||
className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}
|
className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}
|
||||||
>
|
>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="text-muted-foreground truncate">{item.old_name}</div>
|
<div className="text-muted-foreground break-all">{item.old_name}</div>
|
||||||
{item.error ? (
|
{item.error ? (
|
||||||
<div className="text-destructive text-xs mt-1">{item.error}</div>
|
<div className="text-destructive text-xs mt-1">{item.error}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-primary font-medium truncate mt-1">→ {item.new_name}</div>
|
<div className="text-primary font-medium break-all mt-1">→ {item.new_name}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -577,6 +714,100 @@ export function FileManagerPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Metadata Dialog */}
|
||||||
|
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
|
||||||
|
<DialogContent className="max-w-md [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between pr-2">
|
||||||
|
<DialogTitle>File Metadata</DialogTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 rounded-full hover:bg-muted"
|
||||||
|
onClick={() => setShowMetadata(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -757,7 +757,8 @@ export function useDownload() {
|
|||||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100));
|
const completedCount = skippedCount + successCount + errorCount;
|
||||||
|
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
@@ -938,7 +939,8 @@ export function useDownload() {
|
|||||||
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadProgress(Math.round(((skippedCount + successCount + errorCount) / total) * 100));
|
const completedCount = skippedCount + successCount + errorCount;
|
||||||
|
setDownloadProgress(Math.min(100, Math.round((completedCount / total) * 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadingTrack(null);
|
setDownloadingTrack(null);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user