feat(organize): organize arbitrary source dir (e.g. downloads) with dedup + 洗版

- Add OrganizeDirectory service: walk an arbitrary source directory (download
  dir / NAS direct-read path) and organize video files into the destination,
  without requiring the source to be a registered library.
- Dedup: skip media already present in the destination (matched by scanned DB
  identity title+year[/season+episode], robust to dir case/layout, plus a
  filesystem folder fallback).
- 洗版 (resolution replacement): when the source resolution is higher than the
  existing version, replace the lower-res file (+NFO sidecar +DB row). Prefers
  scanned dimensions, then ffprobe, then filename token; never replaces on
  unknown resolution.
- New endpoints: GET /admin/organize/sources (download/media dir candidates)
  and POST /admin/organize/source.
- UI: ToolsPage adds a '整理来源目录(去重+洗版)' form so operators can pick the
  download dir as the organize source.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
soldosluka857
2026-05-30 08:00:10 +00:00
committed by Shuke
parent 7cc59f095c
commit d03f84e78d
8 changed files with 724 additions and 4 deletions
+25
View File
@@ -12,6 +12,15 @@ export interface OrganizeOverrides {
transfer_mode?: string
}
// OrganizeSource is a selectable organize source directory (e.g. the download
// directory) surfaced so operators can organize an arbitrary directory and not
// only registered libraries.
export interface OrganizeSource {
label: string
path: string
kind: string
}
export const toolsAPI = {
organizeMedia: (mediaID: string, opts?: OrganizeOverrides) =>
api
@@ -26,6 +35,22 @@ export const toolsAPI = {
)
.then((r) => r.data),
// organizeSources lists selectable source directories (download/media dir).
organizeSources: () =>
api
.get<{ sources: OrganizeSource[] }>('/admin/organize/sources')
.then((r) => r.data.sources ?? []),
// organizeDirectory organizes an arbitrary source directory (e.g. downloads)
// into the destination with dedup + 洗版 (resolution replacement).
organizeDirectory: (opts: OrganizeOverrides) =>
api
.post<{ organized: number; skipped: number; replaced?: number; errors?: string[] }>(
'/admin/organize/source',
opts,
)
.then((r) => r.data),
notifyTest: (title: string, body: string) =>
api
.post<{ message: string }>('/admin/notify/test', { title, body })
+75 -1
View File
@@ -4,7 +4,7 @@ import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
import { libraryAPI, mediaAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { toolsAPI, type OrganizeSource } from '../api/tools'
import type { Library, Media, Setting } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
@@ -66,6 +66,11 @@ function OrganizePanel() {
const [destPath, setDestPath] = useState('')
const [transferMode, setTransferMode] = useState('')
// 整理来源目录(如下载目录):可选择整个目录作为整理源,不要求是已登记媒体库。
const [sources, setSources] = useState<OrganizeSource[]>([])
const [sourceDir, setSourceDir] = useState('')
const [organizingDir, setOrganizingDir] = useState(false)
const overrides = () => {
const o: { source_path?: string; dest_path?: string; transfer_mode?: string } = {}
if (sourcePath.trim()) o.source_path = sourcePath.trim()
@@ -76,6 +81,13 @@ function OrganizePanel() {
useEffect(() => {
libraryAPI.list().then(setLibraries).catch(() => undefined)
toolsAPI
.organizeSources()
.then((s) => {
setSources(s)
if (s.length > 0) setSourceDir(s[0].path)
})
.catch(() => undefined)
}, [])
useEffect(() => {
@@ -112,6 +124,34 @@ function OrganizePanel() {
}
}
const onOrganizeDir = async (e: FormEvent) => {
e.preventDefault()
const src = (sourceDir || sourcePath).trim()
if (!src) {
toast.error('请选择或填写整理来源目录')
return
}
setOrganizingDir(true)
try {
const r = await toolsAPI.organizeDirectory({
source_path: src,
dest_path: destPath.trim() || undefined,
transfer_mode: transferMode || undefined,
})
const replaced = r.replaced ?? 0
toast.success(
`整理完成:新增 ${r.organized} · 替换(洗版) ${replaced} · 去重跳过 ${r.skipped}`,
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'整理失败'
toast.error(msg)
} finally {
setOrganizingDir(false)
}
}
const doSearch = async (e?: FormEvent) => {
e?.preventDefault()
if (!query.trim()) return
@@ -203,6 +243,40 @@ function OrganizePanel() {
</label>
</div>
<div className="space-y-2 rounded-xl border border-brand-200 bg-brand-50/40 p-3">
<p className="text-xs text-ink-50">
<b>整理来源目录</b>:直接整理整个目录(如下载目录 <code className="rounded bg-gray-100 px-1">/downloads</code>),无需是已登记的媒体库。已存在于目的地的媒体会<b>自动去重跳过</b>,来源<b>分辨率更高</b>时会<b>替换(洗版)</b>旧版本。目的地留空则使用「整理目的地目录」设置或媒体目录。
</p>
<form onSubmit={onOrganizeDir} className="flex flex-wrap gap-2">
<select
className="input-base flex-1 min-w-[220px]"
value={sourceDir}
onChange={(e) => setSourceDir(e.target.value)}
>
{sources.length === 0 && (
<option value="">(无可选来源目录,请在上方「源目录」手动填写)</option>
)}
{sources.map((s) => (
<option key={s.path} value={s.path}>
{s.label}({s.path})
</option>
))}
</select>
<button
type="submit"
disabled={organizingDir || !(sourceDir || sourcePath).trim()}
className="neon-button"
>
{organizingDir ? (
<Loader2 size={16} className="animate-spin" />
) : (
<FolderCog size={16} />
)}
整理来源目录(去重+洗版)
</button>
</form>
</div>
<form onSubmit={onOrganizeLibrary} className="flex flex-wrap gap-2">
<select
required