import { useEffect, useState } from 'react' import { Folder, FolderInput, Sparkles, X, CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react' import toast from 'react-hot-toast' import { libraryAPI } from '../api/library' import { toolsAPI, type OrganizeResultSummary } from '../api/tools' import type { Library } from '../types' import { EpisodeArtworkToggle } from './EpisodeArtworkToggle' interface ScrapeMetadataDialogProps { open: boolean library: Library | null scrapeEpisodeArtwork: boolean onScrapeEpisodeArtworkChange: (checked: boolean) => void onClose: () => void onCompleted?: () => void | Promise } type ScrapeTargetMode = 'current' | 'custom' type ScrapeActionType = 'organize_and_scrape' | 'scrape_only' export function ScrapeMetadataDialog({ open, library, scrapeEpisodeArtwork, onScrapeEpisodeArtworkChange, onClose, onCompleted, }: ScrapeMetadataDialogProps) { const [targetMode, setTargetMode] = useState('current') const [actionType, setActionType] = useState('organize_and_scrape') const [destPath, setDestPath] = useState('') const [transferMode, setTransferMode] = useState('hardlink') const [refreshMatched, setRefreshMatched] = useState(true) const [availableLibraries, setAvailableLibraries] = useState([]) const [busy, setBusy] = useState(false) const [previewBusy, setPreviewBusy] = useState(false) const [previewResult, setPreviewResult] = useState(null) useEffect(() => { if (!open) return setTargetMode('current') setActionType('organize_and_scrape') setDestPath('') setTransferMode('hardlink') setRefreshMatched(true) setPreviewResult(null) // Load available libraries for quick destination selection libraryAPI .list({ includeHidden: true }) .then((libs) => setAvailableLibraries(libs.filter((l) => l.id !== library?.id))) .catch(() => undefined) }, [open, library]) if (!open || !library) return null const isCloud = (library.path || '').toLowerCase().startsWith('cloud://') const effectiveDestPath = targetMode === 'custom' ? destPath.trim() : library.path const isOrganize = targetMode === 'custom' || actionType === 'organize_and_scrape' const handlePreview = async () => { if (previewBusy || busy) return if (targetMode === 'custom' && !destPath.trim()) { toast.error('请输入指定目标文件夹路径') return } setPreviewBusy(true) setPreviewResult(null) try { const result = await toolsAPI.organizeLibrary(library.id, { dest_path: effectiveDestPath, transfer_mode: transferMode, dry_run: true, }) setPreviewResult(result) toast.success('已生成整理预览') } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '预览失败' toast.error(msg) } finally { setPreviewBusy(false) } } const handleExecute = async () => { if (busy || previewBusy) return if (targetMode === 'custom' && !destPath.trim()) { toast.error('请输入指定目标文件夹路径') return } setBusy(true) try { if (!isOrganize) { // Pure metadata scrape await libraryAPI.scrape(library.id, { episode_images: scrapeEpisodeArtwork, refresh_matched: refreshMatched, }) toast.success('刮削任务已加入后台队列') } else { // Organize and scrape const result = await toolsAPI.organizeLibrary(library.id, { dest_path: effectiveDestPath, transfer_mode: transferMode, scan_after: true, scrape_after: true, }) const organized = result.organized ?? 0 const replaced = result.replaced ?? 0 const reclassified = result.reclassified ?? 0 const skipped = result.skipped ?? 0 toast.success(`整理与刮削已启动:新增 ${organized} · 替换 ${replaced} · 纠偏 ${reclassified} · 跳过 ${skipped}`) } await onCompleted?.() onClose() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '操作失败' toast.error(msg) } finally { setBusy(false) } } return (
{/* Header */}

刮削元数据与整理

对媒体库「{library.name}」执行刮削与文件整理

{/* Content Body */}
{isCloud && (
当前为云盘媒体库,文件整理将受限。推荐仅使用元数据刮削,或在外部存储中配置云盘挂载/转移。
)} {/* Mode Selection Cards */}
{/* Option 1: Current Folder */}
setTargetMode('current')} className={`relative flex cursor-pointer flex-col justify-between rounded-xl border p-4 transition-all ${ targetMode === 'current' ? 'border-brand-500 bg-brand-50/40 ring-2 ring-brand-500/20' : 'border-gray-200 bg-white hover:border-gray-300' }`} >
媒体当前文件夹
{targetMode === 'current' && }

直接在当前媒体库目录内进行刮削整理,无需迁移文件。

{library.path}
{/* Option 2: Custom Specified Folder */}
setTargetMode('custom')} className={`relative flex cursor-pointer flex-col justify-between rounded-xl border p-4 transition-all ${ targetMode === 'custom' ? 'border-brand-500 bg-brand-50/40 ring-2 ring-brand-500/20' : 'border-gray-200 bg-white hover:border-gray-300' }`} >
整理到指定文件夹
{targetMode === 'custom' && }

将媒体规范整理转移至指定目标目录,并自动扫描入库刮削。

{destPath.trim() || '需自定义目标路径…'}
{/* Sub-options for Current Folder Mode */} {targetMode === 'current' && (
)} {/* Custom Destination Input (when targetMode === 'custom') */} {targetMode === 'custom' && (
setDestPath(e.target.value)} placeholder="如 /media/organized 或 D:\Media\Movies" className="h-10 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-mono text-gray-800 outline-none focus:border-brand-500 focus:ring-1 focus:ring-brand-500" />
{availableLibraries.length > 0 && (
快捷填入已有媒体库路径:
{availableLibraries.map((lib) => ( ))}
)}
)} {/* Transfer Mode & Advanced Scrape Options */}
{isOrganize && ( )}
刮削偏好设置
{/* Preview Results */} {previewResult && (
整理预览结果:
待新增:{previewResult.organized ?? 0} 替换/洗版:{previewResult.replaced ?? 0} 纠偏:{previewResult.reclassified ?? 0} 已在目标/跳过:{previewResult.skipped ?? 0}
{previewResult.errors && previewResult.errors.length > 0 && (
部分异常 ({previewResult.errors.length}):
    {previewResult.errors.slice(0, 3).map((err, i) => (
  • {err}
  • ))}
)}
)}
{/* Footer Actions */}
{isOrganize && ( )}
) }