From 3419077b673a2980579071b19930e5a516968f8c Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Thu, 25 Jun 2026 06:13:59 +0800 Subject: [PATCH] refactor: split manual scrape dialog --- web/src/components/ManualScrapeDialog.tsx | 302 ++---------------- web/src/components/ManualScrapeDialogModel.ts | 90 ++++++ .../components/ManualScrapeDialogSections.tsx | 229 +++++++++++++ .../components/useManualScrapeDialogState.ts | 275 ++++++++++++++++ 4 files changed, 624 insertions(+), 272 deletions(-) create mode 100644 web/src/components/ManualScrapeDialogModel.ts create mode 100644 web/src/components/ManualScrapeDialogSections.tsx create mode 100644 web/src/components/useManualScrapeDialogState.ts diff --git a/web/src/components/ManualScrapeDialog.tsx b/web/src/components/ManualScrapeDialog.tsx index 5f015e3..1974053 100644 --- a/web/src/components/ManualScrapeDialog.tsx +++ b/web/src/components/ManualScrapeDialog.tsx @@ -1,11 +1,11 @@ -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' -import { EpisodeArtworkToggle } from './EpisodeArtworkToggle' +import { isEpisodeArtworkTarget } from './ManualScrapeDialogModel' +import { + ManualScrapeCandidateList, + ManualScrapeDialogHeader, + ManualScrapeSearchControls, +} from './ManualScrapeDialogSections' +import { useManualScrapeDialogState } from './useManualScrapeDialogState' interface ManualScrapeDialogProps { open: boolean @@ -19,14 +19,6 @@ interface ManualScrapeDialogProps { onApplied?: () => void } -const providers = [ - { value: 'tmdb', label: 'TMDb' }, - { value: 'douban', label: '豆瓣' }, - { value: 'bangumi', label: 'Bangumi' }, - { value: 'thetvdb', label: 'TheTVDB' }, - { value: 'adult', label: 'Adult / 番号' }, -] - export function ManualScrapeDialog({ open, media, @@ -38,274 +30,40 @@ export function ManualScrapeDialog({ onClose, onApplied, }: ManualScrapeDialogProps) { - const [query, setQuery] = useState('') - const [selectedProviders, setSelectedProviders] = useState([]) - const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false) - const [searching, setSearching] = useState(false) - const [applyingKey, setApplyingKey] = useState('') - const [items, setItems] = useState([]) - - 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 || '') - setSelectedProviders([]) - setIncludeEpisodeArtwork(episodeArtwork ?? false) - setItems([]) - setApplyingKey('') - }, [defaultQuery, episodeArtwork, media?.title, open]) + const dialog = useManualScrapeDialogState({ + open, + media, + mediaIds, + defaultQuery, + mediaType, + episodeArtwork, + onClose, + onApplied, + }) if (!open || !media) return null - const runSearch = async () => { - const text = query.trim() - if (!text) { - toast.error('请输入标题或 TMDb/豆瓣/Bangumi/TheTVDB ID') - return - } - setSearching(true) - setItems([]) - const providerValues = manualSearchProvidersForSelection(selectedProviders, text) - if (providerValues.length === 0) { - toast.error('至少选择一个刮削源') - setSearching(false) - return - } - try { - const settled = await Promise.allSettled( - providerValues.map(async (provider) => { - const results = await mediaAPI.manualScrapeSearch(media.id, { - query: text, - provider, - media_type: mediaType, - }) - setItems((current) => mergeManualCandidates(current, results)) - return { provider, results } - }), - ) - const found = settled.reduce((sum, result) => ( - result.status === 'fulfilled' ? sum + result.value.results.length : sum - ), 0) - const failed = settled.filter((result) => result.status === 'rejected').length - if (found === 0) { - toast.error(failed === providerValues.length ? '所有刮削源搜索失败' : '没有找到可用候选') - } else if (failed > 0) { - toast.error(`${failed} 个刮削源搜索失败,已显示其余结果`) - } - } 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 { - const options = { episode_images: includeEpisodeArtwork } - if (targetIds.length > 1) { - const result = await mediaAPI.applyManualScrapeBatch(targetIds, item, options) - toast.success(`已应用到 ${result.applied} 个媒体`) - } else { - await mediaAPI.applyManualScrape(media.id, item, options) - toast.success('已应用手动匹配') - } - onApplied?.() - onClose() - } catch (err: unknown) { - const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '应用失败' - toast.error(msg) - } finally { - setApplyingKey('') - } - } - return (
-
-
-

手动搜索刮削

-

- {scopeLabel || media.title} · {targetIds.length > 1 ? `将应用到 ${targetIds.length} 个媒体` : '单个媒体'} -

-
- -
+ -
-
- 刮削源 -
- - {providers.map((item) => { - const active = selectedProviders.includes(item.value) - return ( - - ) - })} -
-
-
- - 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" - /> -
- - {isEpisodeArtworkTarget(media, mediaType, targetIds.length) && ( - - )} -
+
- {items.length === 0 ? ( -
- 搜索后在这里选择正确的元数据 -
- ) : ( -
- {items.map((item) => { - const key = candidateKey(item) - const applying = applyingKey === key - return ( -
-
- {item.poster_url ? ( - {item.title} - ) : ( -
无海报
- )} -
-
-
-

{item.title}

- {item.source} - {item.nsfw ? 成人 : null} - {item.year ? {item.year} : null} -
-

{item.overview || '暂无简介'}

-

{candidateIDText(item)}

-
- -
- ) - })} -
- )} +
) } - -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 manualSearchProvidersForSelection(selectedProviders: string[], query: string): string[] { - const explicitProvider = providerFromQueryPrefix(query) - if (explicitProvider) return [explicitProvider] - return selectedProviders.length > 0 ? selectedProviders : providers.map((provider) => provider.value) -} - -function toggleProvider(current: string[], value: string): string[] { - if (current.includes(value)) { - return current.filter((item) => item !== value) - } - const next = [...current, value] - return next.length === providers.length ? [] : next -} - -function providerFromQueryPrefix(query: string): string { - const prefix = query.trim().toLowerCase().match(/^([a-z]+)\s*:/)?.[1] - switch (prefix) { - case 'tmdb': - return 'tmdb' - case 'douban': - return 'douban' - case 'bangumi': - case 'bgm': - return 'bangumi' - case 'thetvdb': - case 'tvdb': - return 'thetvdb' - case 'adult': - case 'jav': - return 'adult' - default: - return '' - } -} - -function mergeManualCandidates(current: ManualScrapeCandidate[], incoming: ManualScrapeCandidate[]): ManualScrapeCandidate[] { - if (incoming.length === 0) return current - const byKey = new Map(current.map((item) => [candidateKey(item), item])) - for (const item of incoming) { - byKey.set(candidateKey(item), item) - } - return Array.from(byKey.values()) -} - -function providerButtonClass(active: boolean): string { - return ( - 'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' + - (active - ? 'border-brand-300 bg-brand-50 text-brand-700' - : 'border-sand-200 bg-white text-sand-600 hover:border-brand-200 hover:text-brand-600') - ) -} - -function isEpisodeArtworkTarget(media: Media, mediaType?: string, targetCount = 1): boolean { - const type = (mediaType || '').toLowerCase() - return type === 'tv' || type === 'anime' || type === 'variety' || media.season_num > 0 || media.episode_num > 0 || targetCount > 1 -} - -function candidateIDText(item: ManualScrapeCandidate): string { - const parts = [ - item.tmdb_id ? `TMDb ${item.tmdb_id}` : '', - item.original_name && item.source === 'adult' ? `番号 ${item.original_name}` : '', - 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(' · ') -} diff --git a/web/src/components/ManualScrapeDialogModel.ts b/web/src/components/ManualScrapeDialogModel.ts new file mode 100644 index 0000000..126670b --- /dev/null +++ b/web/src/components/ManualScrapeDialogModel.ts @@ -0,0 +1,90 @@ +import type { ManualScrapeCandidate } from '../api/library' +import type { Media } from '../types' + +export interface ManualSearchProvider { + value: string + label: string +} + +export const manualSearchProviders: ManualSearchProvider[] = [ + { value: 'tmdb', label: 'TMDb' }, + { value: 'douban', label: '豆瓣' }, + { value: 'bangumi', label: 'Bangumi' }, + { value: 'thetvdb', label: 'TheTVDB' }, + { value: 'adult', label: 'Adult / 番号' }, +] + +export 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 || ''}` +} + +export function manualSearchProvidersForSelection(selectedProviders: string[], query: string): string[] { + const explicitProvider = providerFromQueryPrefix(query) + if (explicitProvider) return [explicitProvider] + return selectedProviders.length > 0 ? selectedProviders : manualSearchProviders.map((provider) => provider.value) +} + +export function toggleProvider(current: string[], value: string): string[] { + if (current.includes(value)) { + return current.filter((item) => item !== value) + } + const next = [...current, value] + return next.length === manualSearchProviders.length ? [] : next +} + +export function mergeManualCandidates( + current: ManualScrapeCandidate[], + incoming: ManualScrapeCandidate[], +): ManualScrapeCandidate[] { + if (incoming.length === 0) return current + const byKey = new Map(current.map((item) => [candidateKey(item), item])) + for (const item of incoming) { + byKey.set(candidateKey(item), item) + } + return Array.from(byKey.values()) +} + +export function isEpisodeArtworkTarget(media: Media, mediaType?: string, targetCount = 1): boolean { + const type = (mediaType || '').toLowerCase() + return ( + type === 'tv' || + type === 'anime' || + type === 'variety' || + media.season_num > 0 || + media.episode_num > 0 || + targetCount > 1 + ) +} + +export function candidateIDText(item: ManualScrapeCandidate): string { + const parts = [ + item.tmdb_id ? `TMDb ${item.tmdb_id}` : '', + item.original_name && item.source === 'adult' ? `番号 ${item.original_name}` : '', + 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(' · ') +} + +function providerFromQueryPrefix(query: string): string { + const prefix = query.trim().toLowerCase().match(/^([a-z]+)\s*:/)?.[1] + switch (prefix) { + case 'tmdb': + return 'tmdb' + case 'douban': + return 'douban' + case 'bangumi': + case 'bgm': + return 'bangumi' + case 'thetvdb': + case 'tvdb': + return 'thetvdb' + case 'adult': + case 'jav': + return 'adult' + default: + return '' + } +} diff --git a/web/src/components/ManualScrapeDialogSections.tsx b/web/src/components/ManualScrapeDialogSections.tsx new file mode 100644 index 0000000..28d0a80 --- /dev/null +++ b/web/src/components/ManualScrapeDialogSections.tsx @@ -0,0 +1,229 @@ +import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react' + +import { imageURL } from '../api/client' +import type { ManualScrapeCandidate } from '../api/library' +import { + candidateIDText, + candidateKey, + manualSearchProviders, + toggleProvider, +} from './ManualScrapeDialogModel' +import { EpisodeArtworkToggle } from './EpisodeArtworkToggle' + +export function ManualScrapeDialogHeader({ + title, + targetCount, + onClose, +}: { + title: string + targetCount: number + onClose: () => void +}) { + return ( +
+
+

手动搜索刮削

+

+ {title} · {targetCount > 1 ? `将应用到 ${targetCount} 个媒体` : '单个媒体'} +

+
+ +
+ ) +} + +interface ManualScrapeSearchControlsProps { + query: string + selectedProviders: string[] + searching: boolean + includeEpisodeArtwork: boolean + showEpisodeArtworkToggle: boolean + onQueryChange: (value: string) => void + onProviderChange: (value: string[] | ((current: string[]) => string[])) => void + onSearch: () => void + onEpisodeArtworkChange: (checked: boolean) => void +} + +export function ManualScrapeSearchControls({ + query, + selectedProviders, + searching, + includeEpisodeArtwork, + showEpisodeArtworkToggle, + onQueryChange, + onProviderChange, + onSearch, + onEpisodeArtworkChange, +}: ManualScrapeSearchControlsProps) { + return ( +
+ + + {showEpisodeArtworkToggle && ( + + )} +
+ ) +} + +function ProviderSelector({ + selectedProviders, + onProviderChange, +}: { + selectedProviders: string[] + onProviderChange: (value: string[] | ((current: string[]) => string[])) => void +}) { + return ( +
+ 刮削源 +
+ + {manualSearchProviders.map((item) => { + const active = selectedProviders.includes(item.value) + return ( + + ) + })} +
+
+ ) +} + +function ManualScrapeQueryBar({ + query, + searching, + onQueryChange, + onSearch, +}: { + query: string + searching: boolean + onQueryChange: (value: string) => void + onSearch: () => void +}) { + return ( + <> +
+ + onQueryChange(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') onSearch() }} + 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" + /> +
+ + + ) +} + +export function ManualScrapeCandidateList({ + items, + applyingKey, + onApply, +}: { + items: ManualScrapeCandidate[] + applyingKey: string + onApply: (item: ManualScrapeCandidate) => void +}) { + if (items.length === 0) { + return ( +
+ 搜索后在这里选择正确的元数据 +
+ ) + } + + return ( +
+ {items.map((item) => { + const key = candidateKey(item) + return ( + + ) + })} +
+ ) +} + +function ManualScrapeCandidateRow({ + item, + applying, + disabled, + onApply, +}: { + item: ManualScrapeCandidate + applying: boolean + disabled: boolean + onApply: (item: ManualScrapeCandidate) => void +}) { + return ( +
+
+ {item.poster_url ? ( + {item.title} + ) : ( +
无海报
+ )} +
+
+
+

{item.title}

+ {item.source} + {item.nsfw ? 成人 : null} + {item.year ? {item.year} : null} +
+

{item.overview || '暂无简介'}

+

{candidateIDText(item)}

+
+ +
+ ) +} + +function providerButtonClass(active: boolean): string { + return ( + 'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' + + (active + ? 'border-brand-300 bg-brand-50 text-brand-700' + : 'border-sand-200 bg-white text-sand-600 hover:border-brand-200 hover:text-brand-600') + ) +} diff --git a/web/src/components/useManualScrapeDialogState.ts b/web/src/components/useManualScrapeDialogState.ts new file mode 100644 index 0000000..3e07f1f --- /dev/null +++ b/web/src/components/useManualScrapeDialogState.ts @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react' +import toast from 'react-hot-toast' + +import { mediaAPI, type ManualScrapeCandidate } from '../api/library' +import type { Media } from '../types' +import { + candidateKey, + manualSearchProvidersForSelection, + mergeManualCandidates, +} from './ManualScrapeDialogModel' + +interface ManualScrapeDialogStateOptions { + open: boolean + media: Media | null + mediaIds?: string[] + defaultQuery?: string + mediaType?: string + episodeArtwork?: boolean + onClose: () => void + onApplied?: () => void +} + +interface SearchManualScrapeParams { + media: Media | null + mediaType?: string + query: string + selectedProviders: string[] + setSearching: Dispatch> + setItems: Dispatch> +} + +interface ManualScrapeDialogResetParams { + open: boolean + mediaTitle?: string + defaultQuery?: string + episodeArtwork?: boolean + setQuery: Dispatch> + setSelectedProviders: Dispatch> + setIncludeEpisodeArtwork: Dispatch> + setApplyingKey: Dispatch> + setItems: Dispatch> +} + +interface ApplyManualScrapeActionParams { + media: Media | null + targetIds: string[] + includeEpisodeArtwork: boolean + setApplyingKey: Dispatch> + onClose: () => void + onApplied?: () => void +} + +interface ApplyManualScrapeParams extends ApplyManualScrapeActionParams { + item: ManualScrapeCandidate +} + +export function useManualScrapeDialogState({ + open, + media, + mediaIds, + defaultQuery, + mediaType, + episodeArtwork, + onClose, + onApplied, +}: ManualScrapeDialogStateOptions) { + const form = useManualScrapeFormState() + const targetIds = useMemo(() => manualScrapeTargetIds(media, mediaIds), [media, mediaIds]) + + useManualScrapeDialogReset({ + open, + mediaTitle: media?.title, + defaultQuery, + episodeArtwork, + setQuery: form.setQuery, + setSelectedProviders: form.setSelectedProviders, + setIncludeEpisodeArtwork: form.setIncludeEpisodeArtwork, + setApplyingKey: form.setApplyingKey, + setItems: form.setItems, + }) + + const runSearch = useManualScrapeSearchAction({ + media, + mediaType, + query: form.query, + selectedProviders: form.selectedProviders, + setSearching: form.setSearching, + setItems: form.setItems, + }) + + const apply = useManualScrapeApplyAction({ + media, + targetIds, + includeEpisodeArtwork: form.includeEpisodeArtwork, + setApplyingKey: form.setApplyingKey, + onClose, + onApplied, + }) + + return { ...form, targetIds, runSearch, apply } +} + +function useManualScrapeFormState() { + const [query, setQuery] = useState('') + const [selectedProviders, setSelectedProviders] = useState([]) + const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false) + const [searching, setSearching] = useState(false) + const [applyingKey, setApplyingKey] = useState('') + const [items, setItems] = useState([]) + return { + query, + selectedProviders, + includeEpisodeArtwork, + searching, + applyingKey, + items, + setQuery, + setSelectedProviders, + setIncludeEpisodeArtwork, + setSearching, + setApplyingKey, + setItems, + } +} + +function useManualScrapeDialogReset({ + open, + mediaTitle, + defaultQuery, + episodeArtwork, + setQuery, + setSelectedProviders, + setIncludeEpisodeArtwork, + setApplyingKey, + setItems, +}: ManualScrapeDialogResetParams): void { + useEffect(() => { + if (!open) return + setQuery(defaultQuery || mediaTitle || '') + setSelectedProviders([]) + setIncludeEpisodeArtwork(episodeArtwork ?? false) + setItems([]) + setApplyingKey('') + }, [ + defaultQuery, + episodeArtwork, + mediaTitle, + open, + setApplyingKey, + setIncludeEpisodeArtwork, + setItems, + setQuery, + setSelectedProviders, + ]) +} + +function useManualScrapeSearchAction(params: SearchManualScrapeParams): () => Promise { + const { media, mediaType, query, selectedProviders, setSearching, setItems } = params + return useCallback(() => searchManualScrapeCandidates({ + media, + mediaType, + query, + selectedProviders, + setSearching, + setItems, + }), [media, mediaType, query, selectedProviders, setSearching, setItems]) +} + +function useManualScrapeApplyAction({ + media, + targetIds, + includeEpisodeArtwork, + setApplyingKey, + onClose, + onApplied, +}: ApplyManualScrapeActionParams): (item: ManualScrapeCandidate) => Promise { + return useCallback((item) => applyManualScrapeCandidate({ + media, + targetIds, + includeEpisodeArtwork, + item, + setApplyingKey, + onClose, + onApplied, + }), [includeEpisodeArtwork, media, onApplied, onClose, setApplyingKey, targetIds]) +} + +function manualScrapeTargetIds(media: Media | null, mediaIds?: string[]): string[] { + const ids = (mediaIds && mediaIds.length > 0 ? mediaIds : media ? [media.id] : []).filter(Boolean) + return Array.from(new Set(ids)) +} + +async function searchManualScrapeCandidates({ + media, + mediaType, + query, + selectedProviders, + setSearching, + setItems, +}: SearchManualScrapeParams): Promise { + if (!media) return + const text = query.trim() + if (!text) { + toast.error('请输入标题或 TMDb/豆瓣/Bangumi/TheTVDB ID') + return + } + setSearching(true) + setItems([]) + const providerValues = manualSearchProvidersForSelection(selectedProviders, text) + if (providerValues.length === 0) { + toast.error('至少选择一个刮削源') + setSearching(false) + return + } + try { + const settled = await Promise.allSettled(providerValues.map(async (provider) => { + const results = await mediaAPI.manualScrapeSearch(media.id, { query: text, provider, media_type: mediaType }) + setItems((current) => mergeManualCandidates(current, results)) + return { provider, results } + })) + reportManualScrapeSearchResult(settled, providerValues.length) + } catch (err: unknown) { + toast.error(apiErrorMessage(err, '搜索失败')) + } finally { + setSearching(false) + } +} + +async function applyManualScrapeCandidate({ + media, + targetIds, + includeEpisodeArtwork, + item, + setApplyingKey, + onClose, + onApplied, +}: ApplyManualScrapeParams): Promise { + if (!media) return + setApplyingKey(candidateKey(item)) + try { + const options = { episode_images: includeEpisodeArtwork } + if (targetIds.length > 1) { + const result = await mediaAPI.applyManualScrapeBatch(targetIds, item, options) + toast.success(`已应用到 ${result.applied} 个媒体`) + } else { + await mediaAPI.applyManualScrape(media.id, item, options) + toast.success('已应用手动匹配') + } + onApplied?.() + onClose() + } catch (err: unknown) { + toast.error(apiErrorMessage(err, '应用失败')) + } finally { + setApplyingKey('') + } +} + +function reportManualScrapeSearchResult( + settled: PromiseSettledResult<{ provider: string; results: ManualScrapeCandidate[] }>[], + providerCount: number, +): void { + const found = settled.reduce((sum, result) => ( + result.status === 'fulfilled' ? sum + result.value.results.length : sum + ), 0) + const failed = settled.filter((result) => result.status === 'rejected').length + if (found === 0) { + toast.error(failed === providerCount ? '所有刮削源搜索失败' : '没有找到可用候选') + } else if (failed > 0) { + toast.error(`${failed} 个刮削源搜索失败,已显示其余结果`) + } +} + +function apiErrorMessage(err: unknown, fallback: string): string { + return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || fallback +}