Files
MeBox/web/src/pages/useLibraryAdminActions.tsx
T
truewhile a79bbb80a8 Remove unused recycle bin and add optional local file delete (#26)
Soft-delete restore/purge/list and the recycle_purge job are gone.
Admin delete now removes library index rows, with an optional
delete_files query that also deletes local media + sidecar NFO.
UI copy is renamed from 移入回收站 to 删除, with a confirm checkbox.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 14:12:52 +08:00

224 lines
7.7 KiB
TypeScript

import { useState, type ReactNode } from 'react'
import toast from 'react-hot-toast'
import { api } from '../api/client'
import { libraryAPI } from '../api/library'
import { mediaAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
import { confirmAction, confirmActionResult } from '../components/confirmAction'
import type { Library, Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { LibraryMovieActions } from './LibraryMovieActions'
import { seriesSourceRoot } from './libraryPageModel'
type UseLibraryAdminActionsOptions = {
libraryID: string
role?: string
library: Library | null
selectedSeries: SeriesCard | null
selectedSeriesEpisodes: Media[]
reloadCurrentLibrary: () => void
clearSelectedSeries: () => void
setManualMovie: (media: Media | null) => void
}
export function useLibraryAdminActions({
libraryID,
role,
library,
selectedSeries,
selectedSeriesEpisodes,
reloadCurrentLibrary,
clearSelectedSeries,
setManualMovie,
}: UseLibraryAdminActionsOptions) {
const [scraping, setScraping] = useState(false)
const [scrapeEpisodeArtwork, setScrapeEpisodeArtwork] = useState(false)
const [repairing, setRepairing] = useState(false)
const [seriesToolBusy, setSeriesToolBusy] = useState('')
const [movieToolBusy, setMovieToolBusy] = useState('')
const handleScrape = async () => {
setScraping(true)
try {
await libraryAPI.scrape(libraryID, { episode_images: scrapeEpisodeArtwork, refresh_matched: true })
toast.success('刮削已加入后台队列')
} catch {
toast.error('刮削失败')
} finally {
setScraping(false)
}
}
const handleRepairRescrape = async () => {
if (repairing) return
setRepairing(true)
try {
await toolsAPI.repairAndRescrapeLibrary(libraryID, { episode_images: scrapeEpisodeArtwork, refresh_matched: true })
toast.success('本库修复+重刮已加入后台队列,进度可在任务中查看')
} catch {
toast.error('修复+重刮启动失败')
} finally {
setRepairing(false)
}
}
const runSeriesTool = async (key: string, label: string, action: (media: Media) => Promise<unknown>) => {
if (selectedSeriesEpisodes.length === 0) return
setSeriesToolBusy(key)
try {
for (const ep of selectedSeriesEpisodes) {
await action(ep)
}
toast.success(`${label}完成:${selectedSeriesEpisodes.length} 个媒体`)
reloadCurrentLibrary()
} catch (err: unknown) {
const serverErr = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
const msg = serverErr || (err instanceof Error ? err.message : '')
toast.error(msg ? `${label}失败:${msg}` : `${label}失败`)
} finally {
setSeriesToolBusy('')
}
}
const handleSeriesSmartScrape = () => {
runSeriesTool('scrape', '整剧智能刮削', (media) =>
api.post(`/media/${media.id}/scrape`, smartScrapeOptions(scrapeEpisodeArtwork)),
)
}
const handleSeriesProbe = () => {
runSeriesTool('probe', '整剧媒体轨探测', (media) => api.post(`/media/${media.id}/probe`))
}
const handleSeriesNFO = () => {
runSeriesTool('nfo', '整剧 NFO 写出', (media) => mediaAPI.exportNFO(media.id))
}
const handleSeriesOrganize = async () => {
if (!selectedSeries || selectedSeriesEpisodes.length === 0 || !library) return
const source = seriesSourceRoot(selectedSeriesEpisodes)
if (!source || source.toLowerCase().startsWith('cloud://')) {
toast.error('当前合集不是本地文件夹,无法使用本地整理入库')
return
}
if (!(await confirmAction({
title: '整理当前合集',
message: `来源:${source}\n目标:自动按元数据选择正确分类库,当前库仅作为就近解析范围。`,
confirmText: '开始整理',
}))) return
setSeriesToolBusy('organize')
try {
const result = await toolsAPI.organizeDirectory({
source_path: source,
dest_path: library.path,
scan_after: true,
scrape_after: true,
})
const replaced = result.replaced ?? 0
const reclassified = result.reclassified ?? 0
toast.success(`合集整理完成:新增 ${result.organized ?? 0} · 替换 ${replaced} · 纠偏 ${reclassified} · 跳过 ${result.skipped ?? 0}`)
reloadCurrentLibrary()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '合集整理失败'
toast.error(msg)
} finally {
setSeriesToolBusy('')
}
}
const handleSeriesDelete = async () => {
if (!selectedSeries || selectedSeriesEpisodes.length === 0) return
const result = await confirmActionResult({
title: '删除整剧',
message: `确定从媒体库删除「${seriesTitle(selectedSeries.rep)}」的 ${selectedSeriesEpisodes.length} 个媒体?\n默认仅移除索引记录,磁盘文件会保留。`,
confirmText: '删除',
checkboxLabel: '同时删除本地文件(含同名 NFO)',
})
if (!result.confirmed) return
await runSeriesTool('delete', '整剧删除', (media) => mediaAPI.delete(media.id, { deleteFiles: result.checked }))
clearSelectedSeries()
}
const runMovieTool = async (media: Media, key: string, label: string, action: (media: Media) => Promise<unknown>) => {
const busyKey = `${key}:${media.id}`
setMovieToolBusy(busyKey)
try {
await action(media)
toast.success(`${label}完成:${media.title}`)
reloadCurrentLibrary()
} catch (err: unknown) {
const serverErr = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
const msg = serverErr || (err instanceof Error ? err.message : '')
toast.error(msg ? `${label}失败:${msg}` : `${label}失败`)
} finally {
setMovieToolBusy('')
}
}
const handleMovieSmartScrape = (media: Media) => {
runMovieTool(media, 'scrape', '智能刮削', (item) =>
api.post(`/media/${item.id}/scrape`, smartScrapeOptions(scrapeEpisodeArtwork)),
)
}
const handleMovieProbe = (media: Media) => {
runMovieTool(media, 'probe', '媒体轨探测', (item) => api.post(`/media/${item.id}/probe`))
}
const handleMovieNFO = (media: Media) => {
runMovieTool(media, 'nfo', 'NFO 写出', (item) => mediaAPI.exportNFO(item.id))
}
const handleMovieDelete = async (media: Media) => {
const result = await confirmActionResult({
title: '删除媒体',
message: `确定从媒体库删除「${media.title}」?\n默认仅移除索引记录,磁盘文件会保留。`,
confirmText: '删除',
checkboxLabel: '同时删除本地文件(含同名 NFO)',
})
if (!result.confirmed) return
await runMovieTool(media, 'delete', '删除', (item) => mediaAPI.delete(item.id, { deleteFiles: result.checked }))
}
const movieActions = (media: Media): ReactNode => {
if (role !== 'admin') return undefined
return (
<LibraryMovieActions
media={media}
busy={movieToolBusy.endsWith(`:${media.id}`)}
onSmartScrape={handleMovieSmartScrape}
onManualScrape={setManualMovie}
onProbe={handleMovieProbe}
onNFO={handleMovieNFO}
onDelete={handleMovieDelete}
/>
)
}
return {
scraping,
scrapeEpisodeArtwork,
repairing,
seriesToolBusy,
setScrapeEpisodeArtwork,
handleScrape,
handleRepairRescrape,
handleSeriesSmartScrape,
handleSeriesProbe,
handleSeriesNFO,
handleSeriesOrganize,
handleSeriesDelete,
movieActions,
}
}
function smartScrapeOptions(episodeImages: boolean) {
return {
episode_images: episodeImages,
refresh_matched: true,
include_matched: true,
}
}