This commit is contained in:
afkarxyz
2025-12-19 21:04:12 +07:00
parent 436feb7f7c
commit 66c30de2db
8 changed files with 491 additions and 80 deletions
+5
View File
@@ -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()
+128 -46
View File
@@ -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
+93 -13
View File
@@ -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
@@ -584,18 +584,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">
+3 -2
View File
@@ -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..."}
+244 -13
View File
@@ -36,6 +36,12 @@ const PreviewRenameFiles = (files: string[], format: string): Promise<backend.Re
(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 {
@@ -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<string, { label: string; template: string }> = {
"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<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(() => {
@@ -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 ? (
<>
<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" />
) : (
@@ -363,9 +484,25 @@ export function FileManagerPage() {
<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 && (
<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>
{node.is_dir && node.expanded && node.children && (
@@ -440,7 +577,7 @@ export function FileManagerPage() {
<RotateCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Reset to default</TooltipContent>
<TooltipContent>Reset to Default</TooltipContent>
</Tooltip>
</div>
<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="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{selectedFiles.size} of {allAudioFiles.length} file(s) selected
</span>
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
{allSelected ? "Deselect All" : "Select All"}
</Button>
<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
@@ -538,11 +675,11 @@ export function FileManagerPage() {
className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}
>
<div className="text-sm">
<div className="text-muted-foreground truncate">{item.old_name}</div>
<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 truncate mt-1"> {item.new_name}</div>
<div className="text-primary font-medium break-all mt-1"> {item.new_name}</div>
)}
</div>
</div>
@@ -577,6 +714,100 @@ export function FileManagerPage() {
</DialogFooter>
</DialogContent>
</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>
);
}
+4 -2
View File
@@ -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);
+10
View File
@@ -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;
}