|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|