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
+130 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import toast from 'react-hot-toast'
@@ -14,9 +14,12 @@ import { AppFooter } from './AppFooter'
import { useAuthStore } from '../stores/auth'
import { usePermissionStore } from '../stores/permissions'
import { usePlayProfileStore } from '../stores/playProfile'
import { imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
import { playProfilesAPI } from '../api/play_profiles'
import { requestPIN } from './PinDialog'
import type { PlayProfile } from '../types'
import type { Media, PlayProfile } from '../types'
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
export function Layout() {
const navigate = useNavigate()
@@ -36,6 +39,12 @@ export function Layout() {
const [profiles, setProfiles] = useState<PlayProfile[]>([])
const [searchFocused, setSearchFocused] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchItems, setSearchItems] = useState<Media[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchTotal, setSearchTotal] = useState(0)
const [searchError, setSearchError] = useState('')
const searchSeq = useRef(0)
const searchCards = useMemo(() => groupSeries(searchItems).slice(0, 8), [searchItems])
// Auto-collapse sidebar on smaller tablet screens, and auto-hide drawer on path change
useEffect(() => {
@@ -61,6 +70,48 @@ export function Layout() {
}
}, [fetchPermissions, isPermissionLoading, permissions, user])
useEffect(() => {
if (location.pathname === '/search') {
const query = new URLSearchParams(location.search).get('q') ?? ''
setSearchQuery(query)
}
}, [location.pathname, location.search])
useEffect(() => {
const query = searchQuery.trim()
const seq = ++searchSeq.current
if (!searchFocused || !query) {
setSearchItems([])
setSearchTotal(0)
setSearchError('')
setSearchLoading(false)
return
}
setSearchLoading(true)
setSearchError('')
const timer = window.setTimeout(() => {
mediaAPI
.search(query, 24)
.then((data) => {
if (seq !== searchSeq.current) return
setSearchItems(data.items ?? [])
setSearchTotal(data.total ?? (data.items ?? []).length)
})
.catch(() => {
if (seq !== searchSeq.current) return
setSearchItems([])
setSearchTotal(0)
setSearchError('搜索失败,请稍后再试')
})
.finally(() => {
if (seq === searchSeq.current) setSearchLoading(false)
})
}, 220)
return () => window.clearTimeout(timer)
}, [searchFocused, searchQuery])
useEffect(() => {
if (!user) {
setProfiles([])
@@ -108,6 +159,7 @@ export function Layout() {
e.preventDefault()
if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
setSearchFocused(false)
}
}
@@ -349,8 +401,10 @@ export function Layout() {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onMouseDown={() => setSearchFocused(true)}
onClick={() => setSearchFocused(true)}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
onBlur={() => window.setTimeout(() => setSearchFocused(false), 120)}
placeholder="搜索电影、电视剧、演员、种子站点..."
className="w-full rounded-full border border-gray-200 bg-gray-50/50 py-2.5 pl-11 pr-12 text-sm text-gray-900 placeholder-gray-500 outline-none transition-all duration-300 focus:border-brand-500 focus:bg-white focus:ring-4 focus:ring-brand-100/40"
/>
@@ -359,6 +413,79 @@ export function Layout() {
Enter
</span>
</div>
<AnimatePresence>
{searchFocused && searchQuery.trim() && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ duration: 0.14 }}
onMouseDown={(event) => event.preventDefault()}
className="absolute left-0 right-0 top-full z-50 mt-3 overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl"
>
<div className="max-h-[420px] overflow-y-auto p-2">
{searchLoading && (
<div className="flex items-center gap-2 px-3 py-4 text-sm text-gray-500">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-brand-500 border-t-transparent" />
搜索中...
</div>
)}
{!searchLoading && searchError && (
<div className="px-3 py-4 text-sm text-red-500">{searchError}</div>
)}
{!searchLoading && !searchError && searchCards.length === 0 && (
<div className="px-3 py-4 text-sm text-gray-500">没有找到匹配的本地媒体</div>
)}
{!searchLoading && !searchError && searchCards.length > 0 && (
<div className="space-y-1">
{searchCards.map((card) => (
<Link
key={card.key}
to={seriesCardLink(card)}
onClick={() => setSearchFocused(false)}
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition-colors hover:bg-gray-50"
>
<div className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-gray-100">
{card.rep.poster_url ? (
<img
src={imageURL(card.rep.poster_url)}
alt={card.rep.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-gray-400">
<LibraryIcon size={16} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-gray-900">
{card.rep.title || card.rep.original_name || '未命名媒体'}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-gray-500">
{card.rep.year ? <span>{card.rep.year}</span> : null}
<span>{card.count > 1 ? `${card.count} 集/条目` : '单条媒体'}</span>
{card.rep.width ? <span>{card.rep.width}x{card.rep.height}</span> : null}
</div>
</div>
</Link>
))}
</div>
)}
</div>
<Link
to={`/search?q=${encodeURIComponent(searchQuery.trim())}`}
onClick={() => setSearchFocused(false)}
className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-sm font-semibold text-brand-600 hover:bg-brand-50/60"
>
<span>查看全部搜索结果</span>
<span className="text-xs text-gray-500">
{searchTotal > 0 ? `${searchTotal} 个条目` : 'Enter'}
</span>
</Link>
</motion.div>
)}
</AnimatePresence>
</form>
</div>
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { imageURL } from '../api/client'
import { mediaAPI, type ManualScrapeCandidate } from '../api/library'
import type { Media } from '../types'
interface ManualScrapeDialogProps {
open: boolean
media: Media | null
mediaIds?: string[]
defaultQuery?: string
mediaType?: string
scopeLabel?: string
onClose: () => void
onApplied?: () => void
}
const providers = [
{ value: 'all', label: '全部源' },
{ value: 'tmdb', label: 'TMDb' },
{ value: 'douban', label: '豆瓣' },
{ value: 'bangumi', label: 'Bangumi' },
{ value: 'thetvdb', label: 'TheTVDB' },
]
export function ManualScrapeDialog({
open,
media,
mediaIds,
defaultQuery,
mediaType,
scopeLabel,
onClose,
onApplied,
}: ManualScrapeDialogProps) {
const [query, setQuery] = useState('')
const [provider, setProvider] = useState('all')
const [searching, setSearching] = useState(false)
const [applyingKey, setApplyingKey] = useState('')
const [items, setItems] = useState<ManualScrapeCandidate[]>([])
const targetIds = useMemo(() => {
const ids = (mediaIds && mediaIds.length > 0 ? mediaIds : media ? [media.id] : []).filter(Boolean)
return Array.from(new Set(ids))
}, [media, mediaIds])
useEffect(() => {
if (!open) return
setQuery(defaultQuery || media?.title || '')
setProvider('all')
setItems([])
setApplyingKey('')
}, [defaultQuery, media?.title, open])
if (!open || !media) return null
const runSearch = async () => {
const text = query.trim()
if (!text) {
toast.error('请输入标题或 TMDb/豆瓣/Bangumi/TheTVDB ID')
return
}
setSearching(true)
try {
const results = await mediaAPI.manualScrapeSearch(media.id, {
query: text,
provider,
media_type: mediaType,
})
setItems(results)
if (results.length === 0) toast.error('没有找到可用候选')
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '搜索失败'
toast.error(msg)
} finally {
setSearching(false)
}
}
const apply = async (item: ManualScrapeCandidate) => {
const key = candidateKey(item)
setApplyingKey(key)
try {
if (targetIds.length > 1) {
const result = await mediaAPI.applyManualScrapeBatch(targetIds, item)
toast.success(`已应用到 ${result.applied} 个媒体`)
} else {
await mediaAPI.applyManualScrape(media.id, item)
toast.success('已应用手动匹配')
}
onApplied?.()
onClose()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '应用失败'
toast.error(msg)
} finally {
setApplyingKey('')
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-900/40 px-4 py-8 backdrop-blur-sm">
<div className="flex max-h-[86vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-sand-200 bg-white shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-sand-200 px-5 py-4">
<div>
<h2 className="font-display text-xl font-bold text-ink-600">手动搜索刮削</h2>
<p className="mt-1 text-xs text-sand-500">
{scopeLabel || media.title} · {targetIds.length > 1 ? `将应用到 ${targetIds.length} 个媒体` : '单个媒体'}
</p>
</div>
<button onClick={onClose} className="btn-ghost h-9 w-9 p-0" aria-label="关闭">
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 sm:flex-row">
<select
value={provider}
onChange={(event) => setProvider(event.target.value)}
className="h-11 rounded-xl border border-sand-200 bg-white px-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
>
{providers.map((item) => (
<option key={item.value} value={item.value}>{item.label}</option>
))}
</select>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') runSearch() }}
className="h-11 w-full rounded-xl border border-sand-200 bg-white pl-9 pr-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
placeholder="输入标题或 TMDb / 豆瓣 / Bangumi / TheTVDB ID"
/>
</div>
<button onClick={runSearch} disabled={searching} className="btn-primary h-11 px-5">
{searching ? <LoaderCircle size={16} className="animate-spin" /> : <Sparkles size={16} />}
搜索
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
{items.length === 0 ? (
<div className="flex min-h-56 items-center justify-center rounded-xl border border-dashed border-sand-200 text-sm text-sand-500">
搜索后在这里选择正确的元数据
</div>
) : (
<div className="grid gap-3">
{items.map((item) => {
const key = candidateKey(item)
const applying = applyingKey === key
return (
<div key={key} className="flex gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm">
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
) : (
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold uppercase text-brand-700">{item.source}</span>
{item.year ? <span className="text-xs text-sand-500">{item.year}</span> : null}
</div>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
</div>
<button onClick={() => apply(item)} disabled={!!applyingKey} className="btn-outline h-10 shrink-0 self-center px-3 text-xs">
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
应用匹配
</button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
function candidateKey(item: ManualScrapeCandidate): string {
return `${item.source}:${item.tmdb_id || item.bangumi_id || item.douban_id || item.thetvdb_id || item.title}:${item.media_type || ''}`
}
function candidateIDText(item: ManualScrapeCandidate): string {
const parts = [
item.tmdb_id ? `TMDb ${item.tmdb_id}` : '',
item.douban_id ? `豆瓣 ${item.douban_id}` : '',
item.bangumi_id ? `Bangumi ${item.bangumi_id}` : '',
item.thetvdb_id ? `TheTVDB ${item.thetvdb_id}` : '',
item.media_type ? item.media_type : '',
].filter(Boolean)
return parts.join(' · ')
}
+27 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Play, Layers, Star } from 'lucide-react'
@@ -6,7 +6,7 @@ import { imageURL } from '../api/client'
import type { Media } from '../types'
export const MediaCard = ({
media, progress, count, rating, linkTo, onClick,
media, progress, count, rating, linkTo, onClick, actions,
}: {
media: Media
progress?: number
@@ -14,6 +14,7 @@ export const MediaCard = ({
rating?: number
linkTo?: string
onClick?: () => void
actions?: ReactNode
}) => {
const ref = useRef<HTMLDivElement>(null)
const href = linkTo ?? `/media/${media.id}`
@@ -131,15 +132,35 @@ export const MediaCard = ({
if (onClick) {
return (
<button type="button" onClick={onClick} className="group block w-full text-left">
{card}
</button>
<div className="group relative block w-full">
<button type="button" onClick={onClick} className="block w-full text-left">
{card}
</button>
{actions && (
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{actions}
</div>
)}
</div>
)
}
if (actions) {
return (
<div className="group relative block">
<Link to={href} className="block">
{card}
</Link>
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{actions}
</div>
</div>
)
}
return (
<Link to={href} className="group block">
{card}
{card}
</Link>
)
}