Fix scraping workflows subscriptions bot and recycle bin

This commit is contained in:
ShukeBta
2026-06-17 19:48:17 +08:00
parent 91afbe54a2
commit 850d3db811
43 changed files with 2782 additions and 76 deletions
+169 -2
View File
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { ArrowLeft, Play, Film } from 'lucide-react'
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
import { libraryAPI } from '../api/library'
import { storageAPI, type CloudScanStatus } from '../api/storage_config'
import { api } from '../api/client'
import { recycleAPI } from '../api/recycle'
import type { Library, Media } from '../types'
import { MediaCard } from '../components/MediaCard'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
@@ -13,6 +15,8 @@ import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { useWebSocket } from '../hooks/useWebSocket'
import { confirmAction } from '../components/ConfirmDialog'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
export function LibraryPage() {
const { id = '' } = useParams()
@@ -27,6 +31,10 @@ export function LibraryPage() {
const [scanning, setScanning] = useState(false)
const [scanProgress, setScanProgress] = useState('')
const [scraping, setScraping] = useState(false)
const [seriesToolBusy, setSeriesToolBusy] = useState('')
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
const [manualMovie, setManualMovie] = useState<Media | null>(null)
const [movieToolBusy, setMovieToolBusy] = useState('')
// 剧集模式:选中某个剧集后展开详情
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
@@ -65,6 +73,16 @@ export function LibraryPage() {
return selectedEpisodes.find((s) => s.season === selectedSeason)?.episodes ?? []
}, [selectedEpisodes, selectedSeason])
const selectedSeriesEpisodes = useMemo(
() => selectedEpisodes.flatMap((season) => season.episodes),
[selectedEpisodes],
)
const selectedSeriesMediaIDs = useMemo(
() => selectedSeriesEpisodes.map((ep) => ep.id),
[selectedSeriesEpisodes],
)
useEffect(() => {
if (!id) return
libraryAPI.list().then((all) => {
@@ -255,6 +273,107 @@ export function LibraryPage() {
finally { setScraping(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 msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
toast.error(msg)
} finally {
setSeriesToolBusy('')
}
}
const handleSeriesSmartScrape = () => {
runSeriesTool('scrape', '整剧智能刮削', (media) => api.post(`/media/${media.id}/scrape`))
}
const handleSeriesProbe = () => {
runSeriesTool('probe', '整剧媒体轨探测', (media) => api.post(`/media/${media.id}/probe`))
}
const handleSeriesNFO = () => {
runSeriesTool('nfo', '整剧 NFO 写出', (media) => recycleAPI.exportNFO(media.id))
}
const handleSeriesSoftDelete = async () => {
if (!selectedSeries || selectedSeriesEpisodes.length === 0) return
if (!(await confirmAction({
title: '移入回收站',
message: `将「${seriesTitle(selectedSeries.rep)}」的 ${selectedSeriesEpisodes.length} 个媒体移至回收站? (磁盘文件保留)`,
confirmText: '移入回收站',
}))) return
await runSeriesTool('delete', '整剧移入回收站', (media) => recycleAPI.softDelete(media.id))
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 msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || `${label}失败`
toast.error(msg)
} finally {
setMovieToolBusy('')
}
}
const handleMovieSmartScrape = (media: Media) => {
runMovieTool(media, 'scrape', '智能刮削', (item) => api.post(`/media/${item.id}/scrape`))
}
const handleMovieProbe = (media: Media) => {
runMovieTool(media, 'probe', '媒体轨探测', (item) => api.post(`/media/${item.id}/probe`))
}
const handleMovieNFO = (media: Media) => {
runMovieTool(media, 'nfo', 'NFO 写出', (item) => recycleAPI.exportNFO(item.id))
}
const handleMovieSoftDelete = async (media: Media) => {
if (!(await confirmAction({
title: '移入回收站',
message: `将「${media.title}」移至回收站? (磁盘文件保留)`,
confirmText: '移入回收站',
}))) return
await runMovieTool(media, 'delete', '移入回收站', (item) => recycleAPI.softDelete(item.id))
}
const movieActions = (media: Media) => {
if (role !== 'admin') return undefined
const busy = movieToolBusy.endsWith(`:${media.id}`)
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
return (
<>
<button title="智能刮削" disabled={busy} onClick={() => handleMovieSmartScrape(media)} className={buttonClass}>
<Sparkles size={13} />
</button>
<button title="手动匹配刮削" disabled={busy} onClick={() => setManualMovie(media)} className={buttonClass}>
<Search size={13} />
</button>
<button title="探测媒体轨" disabled={busy} onClick={() => handleMovieProbe(media)} className={buttonClass}>
<Database size={13} />
</button>
<button title="写出本地 NFO" disabled={busy} onClick={() => handleMovieNFO(media)} className={buttonClass}>
<FileText size={13} />
</button>
<button title="移入回收站" disabled={busy} onClick={() => handleMovieSoftDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
<Trash2 size={13} />
</button>
</>
)
}
const handleSeriesClick = (card: SeriesCard) => {
setSelectedSeries(card)
const next = new URLSearchParams(searchParams)
@@ -309,7 +428,7 @@ export function LibraryPage() {
{!isSeries && items.length > 0 && (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
<MediaCard key={m.id} media={m} actions={movieActions(m)} />
))}
</div>
)}
@@ -386,6 +505,34 @@ export function LibraryPage() {
</div>
) : null
})()}
{role === 'admin' && selectedSeriesEpisodes.length > 0 && (
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
<div className="flex flex-wrap gap-2">
<button onClick={handleSeriesSmartScrape} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Sparkles size={13} className="text-[#c9954a]" />
<span>{seriesToolBusy === 'scrape' ? '刮削中…' : '整剧智能刮削'}</span>
</button>
<button onClick={() => setManualSeriesScrapeOpen(true)} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配整剧</span>
</button>
<button onClick={handleSeriesProbe} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<Database size={13} />
<span>{seriesToolBusy === 'probe' ? '探测中…' : '探测媒体轨'}</span>
</button>
<button onClick={handleSeriesNFO} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<FileText size={13} />
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
</button>
<button onClick={handleSeriesSoftDelete} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200">
<Trash2 size={13} />
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
</button>
</div>
</div>
)}
</div>
</div>
@@ -451,6 +598,26 @@ export function LibraryPage() {
<p className="text-ink-50">该库尚未发现任何剧集,触发一次扫描后再来看看</p>
</div>
)}
<ManualScrapeDialog
open={manualSeriesScrapeOpen}
media={selectedSeries?.rep ?? null}
mediaIds={selectedSeriesMediaIDs}
defaultQuery={selectedSeries ? seriesTitle(selectedSeries.rep) : ''}
mediaType={library?.type || 'tv'}
scopeLabel={selectedSeries ? seriesTitle(selectedSeries.rep) : '当前剧集'}
onClose={() => setManualSeriesScrapeOpen(false)}
onApplied={reloadCurrentLibrary}
/>
<ManualScrapeDialog
open={!!manualMovie}
media={manualMovie}
defaultQuery={manualMovie?.title ?? ''}
mediaType={library?.type || 'movie'}
scopeLabel={manualMovie?.title ?? '当前电影'}
onClose={() => setManualMovie(null)}
onApplied={reloadCurrentLibrary}
/>
</div>
)
}
+16 -1
View File
@@ -1,7 +1,7 @@
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database } from 'lucide-react'
import { FileText, Heart, Play, RefreshCw, Sparkles, Trash2, Calendar, Database, Search } from 'lucide-react'
import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
@@ -13,6 +13,7 @@ import { api } from '../api/client'
import type { Media } from '../types'
import { confirmAction } from '../components/ConfirmDialog'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
function fmtDuration(sec: number): string {
if (!sec || sec <= 0) return '—'
@@ -45,6 +46,7 @@ export function MediaDetailPage() {
const [media, setMedia] = useState<Media | null>(null)
const [favourite, setFavourite] = useState(false)
const [loading, setLoading] = useState(true)
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
const refresh = async () => {
if (!id) return
@@ -315,6 +317,10 @@ export function MediaDetailPage() {
<Sparkles size={13} className="text-[#c9954a]" />
<span>智能刮削 (TMDB)</span>
</button>
<button onClick={() => setManualScrapeOpen(true)} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配刮削</span>
</button>
<button onClick={reprobe} className="btn-outline py-2 px-3.5 text-xs gap-1.5 border-gray-200 hover:border-brand-500/50 hover:bg-brand-50">
<Database size={13} className="text-gray-600" />
<span>探测媒体轨 (ffprobe)</span>
@@ -336,6 +342,15 @@ export function MediaDetailPage() {
</div>
</div>
</div>
<ManualScrapeDialog
open={manualScrapeOpen}
media={media}
defaultQuery={media.title}
mediaType={media.season_num > 0 || media.episode_num > 0 ? 'tv' : undefined}
scopeLabel={media.title}
onClose={() => setManualScrapeOpen(false)}
onApplied={refresh}
/>
</div>
)
}
+74 -11
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from 'react'
import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react'
import { Bell, Loader2, Pencil, Play, Plus, Send, Square, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import {
@@ -17,6 +17,8 @@ export function NotifyChannelsPage() {
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<NotifyChannel | null>(null)
const [showForm, setShowForm] = useState(false)
const [testingID, setTestingID] = useState<string | null>(null)
const [pollingBusy, setPollingBusy] = useState<'start' | 'stop' | null>(null)
const refresh = async () => {
setLoading(true)
@@ -32,6 +34,7 @@ export function NotifyChannelsPage() {
}, [])
const onTest = async (id: string) => {
setTestingID(id)
try {
await notifyChannelsAPI.test(id)
toast.success('测试消息已发送')
@@ -39,6 +42,42 @@ export function NotifyChannelsPage() {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败'
toast.error(msg)
} finally {
setTestingID(null)
}
}
const onStartPolling = async () => {
setPollingBusy('start')
try {
const res = await notifyChannelsAPI.startTelegramPolling()
if ((res.started ?? 0) > 0) {
toast.success(`已启动 ${res.started} 个 Telegram Bot 轮询`)
} else if ((res.already_running ?? 0) > 0) {
toast.success('Telegram Bot 轮询已在运行')
} else {
toast.error(res.errors?.[0] ?? '没有可启动的 Telegram Bot 渠道')
}
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动轮询失败'
toast.error(msg)
} finally {
setPollingBusy(null)
}
}
const onStopPolling = async () => {
setPollingBusy('stop')
try {
const res = await notifyChannelsAPI.stopTelegramPolling()
toast.success(res.stopped > 0 ? `已停止 ${res.stopped} 个 Telegram Bot 轮询` : '当前没有运行中的轮询')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '停止轮询失败'
toast.error(msg)
} finally {
setPollingBusy(null)
}
}
@@ -69,15 +108,35 @@ export function NotifyChannelsPage() {
</p>
</div>
</div>
<button
onClick={() => {
setEditing(null)
setShowForm(true)
}}
className="neon-button"
>
<Plus size={16} /> 添加渠道
</button>
<div className="flex flex-wrap justify-end gap-2">
<button
onClick={onStartPolling}
disabled={pollingBusy !== null}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
title="启动 Telegram Bot 长轮询"
>
{pollingBusy === 'start' ? <Loader2 size={16} className="inline animate-spin" /> : <Play size={16} className="inline" />}
{' '}启动 Bot 轮询
</button>
<button
onClick={onStopPolling}
disabled={pollingBusy !== null}
className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:border-primary-400/40 hover:text-brand-500 disabled:opacity-50"
title="停止本地长轮询。"
>
{pollingBusy === 'stop' ? <Loader2 size={16} className="inline animate-spin" /> : <Square size={16} className="inline" />}
{' '}停止轮询
</button>
<button
onClick={() => {
setEditing(null)
setShowForm(true)
}}
className="neon-button"
>
<Plus size={16} /> 添加渠道
</button>
</div>
</div>
{loading && (
@@ -97,6 +156,7 @@ export function NotifyChannelsPage() {
key={ch.id}
channel={ch}
onTest={() => onTest(ch.id)}
testing={testingID === ch.id}
onEdit={() => {
setEditing(ch)
setShowForm(true)
@@ -140,11 +200,13 @@ const EVENT_OPTIONS = [
function ChannelCard({
channel,
onTest,
testing,
onEdit,
onDelete,
}: {
channel: NotifyChannel
onTest: () => void
testing?: boolean
onEdit: () => void
onDelete: () => void
}) {
@@ -167,9 +229,10 @@ function ChannelCard({
<div className="flex shrink-0 gap-2">
<button
onClick={onTest}
disabled={testing}
className="rounded-lg border border-gray-200 px-2 py-1 text-xs text-ink-100 hover:border-primary-400/40 hover:text-brand-500"
>
<Send size={12} className="inline" /> 测试
{testing ? <Loader2 size={12} className="inline animate-spin" /> : <Send size={12} className="inline" />} 测试
</button>
<button
onClick={onEdit}
+104 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import toast from 'react-hot-toast'
import { RotateCcw, Trash2 } from 'lucide-react'
import { Home, RotateCcw, Square, CheckSquare, Trash2 } from 'lucide-react'
import { recycleAPI } from '../api/recycle'
import { confirmAction } from '../components/ConfirmDialog'
@@ -9,32 +10,124 @@ import type { Media } from '../types'
export function RecycleBinPage() {
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(true)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [batchBusy, setBatchBusy] = useState('')
const refresh = () =>
recycleAPI
.list()
.then(setItems)
.then((rows) => {
setItems(rows)
setSelectedIds((current) => current.filter((id) => rows.some((item) => item.id === id)))
})
.finally(() => setLoading(false))
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const allSelected = items.length > 0 && selectedIds.length === items.length
const toggleAll = () => {
setSelectedIds(allSelected ? [] : items.map((item) => item.id))
}
const toggleOne = (id: string) => {
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id])
}
const runBatch = async (mode: 'restore' | 'purge') => {
if (selectedIds.length === 0) return
if (mode === 'purge' && !(await confirmAction({
title: '批量彻底删除记录',
message: `彻底删除选中的 ${selectedIds.length} 条记录? (磁盘文件保留)`,
confirmText: '彻底删除',
}))) return
setBatchBusy(mode)
try {
const result = await runRecycleBatchRequest(mode, selectedIds)
toast.success(`${mode === 'restore' ? '恢复' : '彻底删除'}完成:${result.applied} 条`)
setSelectedIds([])
await refresh()
} catch (err: unknown) {
const response = (err as { response?: { status?: number; data?: { error?: string } }; message?: string })?.response
const msg = response?.data?.error || (response?.status ? `批量操作失败 (${response.status})` : (err as { message?: string })?.message || '批量操作失败')
toast.error(msg)
} finally {
setBatchBusy('')
}
}
const runRecycleBatchRequest = async (mode: 'restore' | 'purge', ids: string[]) => {
try {
return mode === 'restore'
? await recycleAPI.restoreMany(ids)
: await recycleAPI.purgeMany(ids)
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status
if (status !== 404 && status !== 405) throw err
let applied = 0
for (const id of ids) {
if (mode === 'restore') {
await recycleAPI.restore(id)
} else {
await recycleAPI.purge(id)
}
applied += 1
}
return { applied, errors: [] as string[] }
}
}
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
<p className="text-sm text-ink-50">
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">回收站</h1>
<p className="mt-2 text-sm text-ink-50">
软删除的媒体保留在数据库中,可以恢复。彻底删除不会移除磁盘上的文件,只会从数据库清除条目。
</p>
<p className="mt-1 text-xs text-sand-500">系统最多保留最新 200 条回收站记录,超过后会自动清理旧记录。</p>
</div>
<Link to="/" className="btn-outline shrink-0 py-2.5 px-4 text-sm">
<Home size={15} />
返回系统首页
</Link>
</div>
{loading && <p className="text-sand-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-ink-50">回收站为空。</p>}
{items.length > 0 && (
<div className="glass-panel">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-sand-500">
已选择 {selectedIds.length} / {items.length} 条
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => runBatch('restore')}
disabled={selectedIds.length === 0 || !!batchBusy}
className="btn-outline py-2 px-3 text-xs"
>
<RotateCcw size={13} />
{batchBusy === 'restore' ? '恢复中…' : '批量恢复'}
</button>
<button
onClick={() => runBatch('purge')}
disabled={selectedIds.length === 0 || !!batchBusy}
className="btn-outline py-2 px-3 text-xs !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200"
>
<Trash2 size={13} />
{batchBusy === 'purge' ? '删除中…' : '批量彻底删除'}
</button>
</div>
</div>
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 py-2">
<button onClick={toggleAll} className="text-sand-500 hover:text-brand-500" aria-label="全选">
{allSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</th>
<th className="py-2">标题</th>
<th>路径</th>
<th className="text-right">操作</th>
@@ -43,6 +136,11 @@ export function RecycleBinPage() {
<tbody>
{items.map((m) => (
<tr key={m.id} className="border-t border-gray-200">
<td className="py-2">
<button onClick={() => toggleOne(m.id)} className="text-sand-500 hover:text-brand-500" aria-label={`选择 ${m.title}`}>
{selectedIds.includes(m.id) ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</td>
<td className="py-2 text-ink-600">{m.title}</td>
<td className="max-w-md truncate text-ink-50" title={m.path}>
{m.path}
+7
View File
@@ -1,4 +1,5 @@
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { CheckCircle2, Info, Rss, Sparkles } from 'lucide-react'
@@ -13,6 +14,8 @@ import { groupSeries, seriesCardLink } from '../utils/groupSeries'
const LOCAL_SEARCH_PAGE_SIZE = 2000
export function SearchPage() {
const [searchParams] = useSearchParams()
const urlQuery = searchParams.get('q') ?? ''
const [q, setQ] = useState('')
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(false)
@@ -34,6 +37,10 @@ export function SearchPage() {
.catch(() => setAiAvailable(false))
}, [])
useEffect(() => {
setQ(urlQuery)
}, [urlQuery])
const doQuickSearch = useCallback((query: string) => {
const seq = ++searchSeq.current
if (!query.trim()) {