mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
a79bbb80a8
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>
224 lines
7.7 KiB
TypeScript
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,
|
|
}
|
|
}
|