support multiple roots per library

This commit is contained in:
ShukeBta
2026-06-26 22:18:26 +08:00
parent bdc3a169f7
commit 5ef831f830
25 changed files with 1382 additions and 171 deletions
+24 -1
View File
@@ -1,5 +1,5 @@
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
import type { Library, Media, ScanResult } from '../types'
import type { Library, LibraryRoot, Media, ScanResult } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
export interface MediaPage {
@@ -23,6 +23,13 @@ export interface SeriesPage {
page_size: number
}
export interface LibraryRootInput {
name?: string
path: string
enabled?: boolean
sort_order?: number
}
export interface ManualScrapeCandidate {
source: string
media_type?: string
@@ -83,11 +90,27 @@ export const libraryAPI = {
create: (name: string, path: string, type: string) =>
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
createWithRoots: (name: string, type: string, roots: LibraryRootInput[]) =>
api.post<Library>('/libraries', { name, type, roots }).then((r) => r.data),
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
listRoots: (id: string) => api.get<LibraryRoot[]>(`/libraries/${id}/roots`).then((r) => r.data),
addRoot: (id: string, root: LibraryRootInput) =>
api.post<LibraryRoot>(`/libraries/${id}/roots`, root).then((r) => r.data),
updateRoot: (id: string, rootID: string, root: Partial<LibraryRootInput>) =>
api.patch<LibraryRoot>(`/libraries/${id}/roots/${rootID}`, root).then((r) => r.data),
removeRoot: (id: string, rootID: string) => api.delete(`/libraries/${id}/roots/${rootID}`).then((r) => r.data),
scan: (id: string) =>
api.post<ScanResult>(`/libraries/${id}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
scanRoot: (id: string, rootID: string) =>
api.post<ScanResult>(`/libraries/${id}/roots/${rootID}/scan`, null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
scrape: (id: string, options?: ScrapeOptions) =>
api.post(`/libraries/${id}/scrape`, options ?? null, { timeout: BATCH_REQUEST_TIMEOUT }).then((r) => r.data),
+225 -18
View File
@@ -1,16 +1,22 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Trash2 } from 'lucide-react'
import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react'
import { libraryAPI } from '../api/library'
import type { Library } from '../types'
import { libraryAPI, type LibraryRootInput } from '../api/library'
import type { Library, LibraryRoot } from '../types'
import { confirmAction } from '../components/confirmAction'
type RootDraft = LibraryRootInput
const emptyRootDraft = (): RootDraft => ({ name: '', path: '', enabled: true })
export function AdminLibraryPanel() {
const [libs, setLibs] = useState<Library[]>([])
const [name, setName] = useState('')
const [path, setPath] = useState('')
const [roots, setRoots] = useState<RootDraft[]>([emptyRootDraft()])
const [type, setType] = useState('movie')
const [newRootByLibrary, setNewRootByLibrary] = useState<Record<string, RootDraft>>({})
const [rootDrafts, setRootDrafts] = useState<Record<string, RootDraft>>({})
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
useEffect(() => {
@@ -20,10 +26,17 @@ export function AdminLibraryPanel() {
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await libraryAPI.create(name, path, type)
const payload = roots
.map((root, index) => ({ ...root, path: root.path.trim(), name: root.name?.trim(), sort_order: index }))
.filter((root) => root.path)
if (payload.length === 0) {
toast.error('请至少填写一个路径')
return
}
await libraryAPI.createWithRoots(name, type, payload)
toast.success('媒体库已创建')
setName('')
setPath('')
setRoots([emptyRootDraft()])
await refresh()
} catch (err: unknown) {
const msg =
@@ -33,6 +46,72 @@ export function AdminLibraryPanel() {
}
}
const updateCreateRoot = (index: number, patch: Partial<RootDraft>) => {
setRoots((prev) => prev.map((root, i) => (i === index ? { ...root, ...patch } : root)))
}
const addCreateRoot = () => setRoots((prev) => [...prev, emptyRootDraft()])
const removeCreateRoot = (index: number) => {
setRoots((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)))
}
const newRootDraft = (libraryID: string) => newRootByLibrary[libraryID] ?? emptyRootDraft()
const setNewRootDraft = (libraryID: string, patch: Partial<RootDraft>) => {
setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: { ...newRootDraft(libraryID), ...patch } }))
}
const addLibraryRoot = async (libraryID: string) => {
const draft = newRootDraft(libraryID)
if (!draft.path?.trim()) {
toast.error('请填写路径')
return
}
await libraryAPI.addRoot(libraryID, { ...draft, path: draft.path.trim(), name: draft.name?.trim() })
setNewRootByLibrary((prev) => ({ ...prev, [libraryID]: emptyRootDraft() }))
toast.success('路径已添加')
await refresh()
}
const rootDraftKey = (libraryID: string, rootID: string) => `${libraryID}:${rootID}`
const editableRootDraft = (libraryID: string, root: LibraryRoot): RootDraft => {
const key = rootDraftKey(libraryID, root.id)
return rootDrafts[key] ?? {
name: root.name ?? '',
path: root.path,
enabled: root.enabled,
sort_order: root.sort_order,
}
}
const setEditableRootDraft = (libraryID: string, root: LibraryRoot, patch: Partial<RootDraft>) => {
const key = rootDraftKey(libraryID, root.id)
setRootDrafts((prev) => ({ ...prev, [key]: { ...editableRootDraft(libraryID, root), ...patch } }))
}
const saveLibraryRoot = async (libraryID: string, root: LibraryRoot) => {
const draft = editableRootDraft(libraryID, root)
if (!draft.path?.trim()) {
toast.error('请填写路径')
return
}
await libraryAPI.updateRoot(libraryID, root.id, {
name: draft.name?.trim(),
path: draft.path.trim(),
enabled: draft.enabled,
sort_order: draft.sort_order,
})
setRootDrafts((prev) => {
const next = { ...prev }
delete next[rootDraftKey(libraryID, root.id)]
return next
})
toast.success('路径已保存')
await refresh()
}
return (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
@@ -43,17 +122,6 @@ export function AdminLibraryPanel() {
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
required
className="input-base md:col-span-2"
placeholder="容器路径,如 /media/电视剧/国产剧"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
宿主机路径,系统会尝试按 compose 挂载自动转换。
</p>
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
@@ -61,6 +129,41 @@ export function AdminLibraryPanel() {
<option value="anime">动漫</option>
<option value="music">音乐</option>
</select>
<div className="md:col-span-4 space-y-2">
{roots.map((root, index) => (
<div key={index} className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<input
className="input-base"
placeholder="路径名称"
value={root.name ?? ''}
onChange={(e) => updateCreateRoot(index, { name: e.target.value })}
/>
<input
required={index === 0}
className="input-base"
placeholder="容器路径,如 /media/电视剧/国产剧"
value={root.path}
onChange={(e) => updateCreateRoot(index, { path: e.target.value })}
/>
<button
type="button"
className="rounded-lg border border-red-400/40 px-3 text-red-400 hover:bg-red-400/10 disabled:opacity-40"
disabled={roots.length <= 1}
onClick={() => removeCreateRoot(index)}
title="删除路径"
>
<Trash2 size={16} />
</button>
</div>
))}
<button type="button" className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm" onClick={addCreateRoot}>
<Plus size={16} /> 添加路径
</button>
</div>
<p className="md:col-span-4 -mt-2 text-xs text-sand-500">
Docker 部署时请优先填写容器内路径,例如 /media/电影、/media/电视剧/国产剧;如果误填 NAS
宿主机路径,系统会尝试按 compose 挂载自动转换。
</p>
<button type="submit" className="neon-button md:col-span-4">
新建媒体库
</button>
@@ -80,7 +183,111 @@ export function AdminLibraryPanel() {
{libs.map((l) => (
<tr key={l.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">{l.name}</td>
<td className="text-ink-100">{l.path}</td>
<td className="text-ink-100">
<div className="space-y-2">
{(l.roots?.length
? l.roots
: [{
id: '',
library_id: l.id,
path: l.path,
enabled: l.enabled,
name: '',
sort_order: 0,
created_at: l.created_at,
updated_at: l.updated_at,
}]).map((root) => (
<div key={root.id || root.path} className="rounded border border-gray-200/70 p-2">
<div className="grid gap-2 xl:grid-cols-[minmax(120px,0.8fr)_minmax(220px,2fr)_auto]">
{root.id ? (
<>
<input
className="input-base"
placeholder="路径名称"
value={editableRootDraft(l.id, root).name ?? ''}
onChange={(e) => setEditableRootDraft(l.id, root, { name: e.target.value })}
/>
<input
className="input-base"
placeholder="真实路径"
value={editableRootDraft(l.id, root).path}
onChange={(e) => setEditableRootDraft(l.id, root, { path: e.target.value })}
/>
</>
) : (
<span className="min-w-0 break-all xl:col-span-2">{root.name ? `${root.name}:${root.path}` : root.path}</span>
)}
<div className="flex flex-wrap items-center gap-2">
{root.id && (
<button
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
title="保存路径"
onClick={() => saveLibraryRoot(l.id, root)}
>
<Save size={14} />
</button>
)}
<button
className="rounded border border-primary-400/40 p-1 text-brand-500 hover:bg-primary-400/10"
title="扫描路径"
onClick={async () => {
if (!root.id) return
await libraryAPI.scanRoot(l.id, root.id)
toast.success('路径扫描已加入后台任务')
}}
>
<RefreshCw size={14} />
</button>
{root.id && (
<button
className="rounded border border-gray-300 px-2 py-1 text-xs"
onClick={async () => {
const enabled = !editableRootDraft(l.id, root).enabled
setEditableRootDraft(l.id, root, { enabled })
await libraryAPI.updateRoot(l.id, root.id, { enabled })
await refresh()
}}
>
{editableRootDraft(l.id, root).enabled ? '启用' : '禁用'}
</button>
)}
{root.id && (
<button
className="rounded border border-red-400/40 p-1 text-red-400 hover:bg-red-400/10"
title="删除路径"
onClick={async () => {
if (!(await confirmAction({ title: '删除媒体库路径', message: `确定删除「${root.path}」?`, confirmText: '删除' }))) return
await libraryAPI.removeRoot(l.id, root.id)
toast.success('路径已删除')
await refresh()
}}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
</div>
))}
<div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<input
className="input-base"
placeholder="路径名称"
value={newRootDraft(l.id).name ?? ''}
onChange={(e) => setNewRootDraft(l.id, { name: e.target.value })}
/>
<input
className="input-base"
placeholder="新增路径"
value={newRootDraft(l.id).path}
onChange={(e) => setNewRootDraft(l.id, { path: e.target.value })}
/>
<button className="rounded-lg border px-3 py-2 text-sm" onClick={() => addLibraryRoot(l.id)}>
<Plus size={14} />
</button>
</div>
</div>
</td>
<td className="text-ink-100">{l.type}</td>
<td className="space-x-2 py-2 text-right">
<button
@@ -97,6 +97,7 @@ export function AutoOrganizeBasicTab({
<BooleanSetting config={config} settingKey="afterDownload" label="qB 下载完成后自动整理" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="downloadSmartClassify" label="下载器智能分类" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="smartClassify" label="智能分类到子库" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="autoAddLibrary" label="自动注册目的地媒体库" onConfigChange={onConfigChange} />
<BooleanSetting config={config} settingKey="keepSeeding" label="保种" onConfigChange={onConfigChange} />
</div>
</>
+4
View File
@@ -6,6 +6,7 @@ export type AutoOrganizeConfig = {
scrapeAfter: string
downloadSmartClassify: string
smartClassify: string
autoAddLibrary: string
sourceDir: string
targetDir: string
transferMode: string
@@ -27,6 +28,7 @@ export const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
scrapeAfter: 'true',
downloadSmartClassify: 'true',
smartClassify: 'true',
autoAddLibrary: 'true',
sourceDir: '',
targetDir: '',
transferMode: 'hardlink',
@@ -48,6 +50,7 @@ export const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
scrapeAfter: 'organize.scrape_after',
downloadSmartClassify: 'downloads.smart_classify',
smartClassify: 'organizer.smart_classify',
autoAddLibrary: 'organize.auto_add_library',
sourceDir: 'organize.source_dir',
targetDir: 'organize.target_dir',
transferMode: 'organize.transfer_mode',
@@ -73,6 +76,7 @@ export function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter,
downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify,
smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify,
autoAddLibrary: idx[AUTO_ORGANIZE_KEYS.autoAddLibrary] ?? AUTO_ORGANIZE_DEFAULTS.autoAddLibrary,
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
+14
View File
@@ -95,12 +95,24 @@ export interface LoginResponse {
}
// Library
export interface LibraryRoot {
id: string
library_id: string
name?: string
path: string
enabled: boolean
sort_order: number
created_at: string
updated_at: string
}
export interface Library {
id: string
name: string
path: string
type: string
enabled: boolean
roots?: LibraryRoot[]
created_at: string
updated_at: string
}
@@ -108,6 +120,7 @@ export interface Library {
export interface Media {
id: string
library_id: string
library_root_id?: string
library_name?: string
library_path?: string
display_library_id?: string
@@ -118,6 +131,7 @@ export interface Media {
original_name?: string
episode_title?: string
path: string
relative_path?: string
size_bytes: number
duration_sec: number
width: number