Files
MeBox/web/src/components/OrganizeMediaDialog.tsx
T
ShukeBta b40e2c7280 fix: 订阅恢复补缺集/单媒体手动整理入口/媒体库排序/网盘媒体分类迁移
- 订阅(问题1): selectPreparedSubscriptionCandidates 在本地已有部分集时不再
  无条件丢弃整季/全集包,单集候选不足时用全集包兜底;Restore 重置 total_episodes
  让下次 run 从权威元数据重算,避免被 feed 低估值锁死误判'无缺集'
- 单媒体整理(问题2): 后端已具备,新增 OrganizeMediaDialog 组件并在媒体详情页
  接入'整理入库'按钮(可选类型/二级分类/目标路径/转移方式/预览)
- 媒体库排序(问题3): ListByLibrariesFiltered 改多级排序 year/updated_at/
  created_at/id,id 作稳定 tie-breaker 消除云盘批量扫描同批时间戳的随机观感
- 网盘媒体消失(问题4): 云盘媒体 upsert 时允许 library_id 迁移到重新挂载的
  更精确分类库(本地媒体物理位置固定不迁移),修复一键挂载子目录后媒体消失
2026-06-19 21:29:02 +08:00

202 lines
7.9 KiB
TypeScript

import { useEffect, useState } from 'react'
import { FolderInput, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { toolsAPI, type OrganizeOverrides } from '../api/tools'
import type { Media } from '../types'
interface OrganizeMediaDialogProps {
open: boolean
media: Media | null
onClose: () => void
onOrganized?: () => void | Promise<void>
}
// OrganizeMediaDialog 对单个媒体执行「手动整理入库」:把该媒体按命名规范转移到
// 目标媒体目录,并可手动覆盖类型/二级分类/目标路径/转移方式。后端 handler 为
// POST /admin/media/:id/organize(见 organizeMediaHandler)。云盘媒体不支持本地整理。
export function OrganizeMediaDialog({ open, media, onClose, onOrganized }: OrganizeMediaDialogProps) {
const [form, setForm] = useState({
media_type: '',
media_category: '',
dest_path: '',
transfer_mode: 'hardlink',
scan_after: true,
scrape_after: true,
dry_run: false,
})
const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState('')
useEffect(() => {
if (!open) return
setPreview('')
setForm((prev) => ({ ...prev, media_type: '', media_category: '', dest_path: '', dry_run: false }))
}, [open, media])
if (!open || !media) return null
const isCloud = (media.path || '').toLowerCase().startsWith('cloud://')
const set = (key: keyof typeof form, value: string | boolean) => {
setForm((prev) => ({ ...prev, [key]: value }))
}
const run = async (dryRun: boolean) => {
if (busy) return
setBusy(true)
setPreview('')
try {
const opts: OrganizeOverrides = {
transfer_mode: form.transfer_mode,
scan_after: form.scan_after,
scrape_after: form.scrape_after,
dry_run: dryRun,
}
if (form.media_type.trim()) opts.media_type = form.media_type.trim()
if (form.media_category.trim()) opts.media_category = form.media_category.trim()
if (form.dest_path.trim()) opts.dest_path = form.dest_path.trim()
const r = await toolsAPI.organizeMedia(media.id, opts)
if (dryRun) {
setPreview(r.path || '(未返回目标路径)')
toast.success('已生成整理预览')
} else {
toast.success('整理完成')
await onOrganized?.()
onClose()
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '整理失败'
toast.error(msg)
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
<div className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-gray-200 px-5 py-4">
<div>
<h2 className="font-display text-xl font-bold text-gray-900">整理入库</h2>
<p className="mt-1 text-xs text-gray-500">
把「{media.title}」按命名规范转移到媒体目录。留空则自动识别类型与二级分类。
</p>
</div>
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
<X size={16} />
</button>
</div>
<div className="grid flex-1 gap-4 overflow-y-auto p-5 md:grid-cols-2">
{isCloud && (
<div className="md:col-span-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-700">
该媒体位于云盘库,本地整理无法直接移动云盘文件。请在外部存储中启用云盘转移,或对本地媒体使用本功能。
</div>
)}
<label>
<span className="mb-1 block text-xs font-bold text-gray-500">类型(留空自动识别)</span>
<select
value={form.media_type}
onChange={(e) => set('media_type', e.target.value)}
className="h-11 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
>
<option value="">自动识别</option>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="anime">动漫</option>
<option value="variety">综艺</option>
<option value="adult">成人</option>
</select>
</label>
<Field
label="二级分类(留空自动识别)"
value={form.media_category}
onChange={(v) => set('media_category', v)}
placeholder="如 华语电影 / 国产剧 / 日番"
/>
<Field
label="目标媒体目录(留空用默认)"
value={form.dest_path}
onChange={(v) => set('dest_path', v)}
placeholder="如 /media"
/>
<label>
<span className="mb-1 block text-xs font-bold text-gray-500">转移方式</span>
<select
value={form.transfer_mode}
onChange={(e) => set('transfer_mode', e.target.value)}
className="h-11 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
>
<option value="hardlink">硬链接</option>
<option value="symlink">软链接</option>
<option value="copy">复制</option>
<option value="move">移动</option>
</select>
</label>
<label className="flex h-11 items-center gap-2 rounded-xl border border-gray-200 px-3 text-sm font-semibold text-gray-700">
<input
type="checkbox"
checked={form.scrape_after}
onChange={(e) => set('scrape_after', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600"
/>
整理后刮削
</label>
<label className="flex h-11 items-center gap-2 rounded-xl border border-gray-200 px-3 text-sm font-semibold text-gray-700">
<input
type="checkbox"
checked={form.scan_after}
onChange={(e) => set('scan_after', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600"
/>
整理后扫描入库
</label>
{preview && (
<div className="md:col-span-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-700">
<span className="font-bold text-gray-500">预览目标路径:</span>
<span className="break-all">{preview}</span>
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-gray-200 px-5 py-4">
<button onClick={onClose} className="btn-outline px-4">取消</button>
<button onClick={() => run(true)} disabled={busy || isCloud} className="btn-outline px-4">
预览路径
</button>
<button onClick={() => run(false)} disabled={busy || isCloud} className="btn-primary px-5">
<FolderInput size={16} />
{busy ? '整理中…' : '整理入库'}
</button>
</div>
</div>
</div>
)
}
function Field({
label,
value,
onChange,
placeholder,
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
}) {
return (
<label>
<span className="mb-1 block text-xs font-bold text-gray-500">{label}</span>
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-11 w-full rounded-xl border border-gray-200 bg-white px-3 text-sm font-semibold text-gray-700 outline-none focus:border-brand-300"
/>
</label>
)
}