From 34c9cb42444fac661f0fc00188c9fb36374742ce Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:58:04 +0800 Subject: [PATCH] fix: improve discover and manual scrape flows --- internal/service/manual_scrape.go | 16 ++- internal/service/manual_scrape_test.go | 18 +++ internal/service/media_series.go | 20 ++- internal/service/media_series_test.go | 34 +++++ web/src/components/ManualScrapeDialog.tsx | 152 +++++++++++++++++----- web/src/pages/DiscoverPage.tsx | 64 ++++++--- web/src/pages/SiteFormBasicFields.tsx | 19 --- web/src/utils/groupSeries.ts | 11 +- 8 files changed, 255 insertions(+), 79 deletions(-) diff --git a/internal/service/manual_scrape.go b/internal/service/manual_scrape.go index 7f7351b..6593895 100644 --- a/internal/service/manual_scrape.go +++ b/internal/service/manual_scrape.go @@ -399,8 +399,8 @@ func (s *ScraperService) manualDoubanMatch(ctx context.Context, query string) *M if s.douban == nil || !s.douban.Enabled() { return nil } - if _, ok := parsePositiveInt(query); ok { - if match, err := s.douban.GetMatchByID(ctx, query); err == nil && match != nil { + if id, ok := parsePositiveIDString(query); ok { + if match, err := s.douban.GetMatchByID(ctx, id); err == nil && match != nil { return match } } @@ -431,8 +431,8 @@ func (s *ScraperService) manualTheTVDBMatch(ctx context.Context, query string) * if s.thetvdb == nil || !s.thetvdb.Enabled() { return nil } - if _, ok := parsePositiveInt(normalizeTheTVDBSeriesID(query)); ok { - if match, err := s.thetvdb.GetSeriesMatchByID(ctx, query); err == nil && match != nil { + if id, ok := parsePositiveIDString(normalizeTheTVDBSeriesID(query)); ok { + if match, err := s.thetvdb.GetSeriesMatchByID(ctx, id); err == nil && match != nil { return match } } @@ -508,6 +508,14 @@ func parsePositiveInt(value string) (int, bool) { return id, err == nil && id > 0 } +func parsePositiveIDString(value string) (string, bool) { + id, ok := parsePositiveInt(value) + if !ok { + return "", false + } + return strconv.Itoa(id), true +} + func manualScrapeBatchName(ids []string) string { if len(ids) == 1 { return ids[0] diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index 5998b97..4b045c0 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -32,6 +32,24 @@ func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) { } } +func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) { + cases := map[string]string{ + "12345": "12345", + "tmdb:12345": "12345", + "douban:67890": "67890", + "thetvdb:24680": "24680", + } + for input, want := range cases { + got, ok := parsePositiveIDString(input) + if !ok || got != want { + t.Fatalf("parsePositiveIDString(%q) = %q,%v; want %q,true", input, got, ok, want) + } + } + if got, ok := parsePositiveIDString("tmdb:not-a-number"); ok || got != "" { + t.Fatalf("parsePositiveIDString invalid = %q,%v; want empty,false", got, ok) + } +} + func TestManualSearchReturnsTMDbCandidatePage(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/service/media_series.go b/internal/service/media_series.go index a097dce..2d716e7 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -169,10 +169,11 @@ func compactSeriesKey(raw string) string { } var ( - seriesYearRE = regexp.MustCompile(`\s*\((?:19|20)\d{2}\)\s*`) - seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*`) - seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`) - seriesSpacerRE = regexp.MustCompile(`[\s._-]+`) + seriesYearRE = regexp.MustCompile(`\s*\((?:19|20)\d{2}\)\s*`) + seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\s*`) + seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`) + seriesSpacerRE = regexp.MustCompile(`[\s._-]+`) + seriesSpecialSuffixRE = regexp.MustCompile(`(?i)(?:\s+(?:specials?|sp|ova|oad|extra|extras)|\s*(?:特别篇|特別篇|番外|特典|外传|外傳))$`) ) func normalizeSeriesTitle(value string) string { @@ -184,6 +185,15 @@ func normalizeSeriesTitle(value string) string { return strings.TrimSpace(value) } +func normalizeSeriesPathTitle(value string) string { + title := normalizeSeriesTitle(value) + stripped := strings.TrimSpace(seriesSpecialSuffixRE.ReplaceAllString(title, "")) + if stripped != "" { + return stripped + } + return title +} + func seriesTitleFromMediaPath(path string) string { if strings.TrimSpace(path) == "" { return "" @@ -199,7 +209,7 @@ func seriesTitleFromMediaPath(path string) string { if dirIndex < 0 { return "" } - return normalizeSeriesTitle(parts[dirIndex]) + return normalizeSeriesPathTitle(parts[dirIndex]) } func seriesDisplayTitle(media model.Media) string { diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index a8d9aed..77c42dd 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -27,3 +27,37 @@ func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) { t.Fatalf("cards=%#v, want one merged series card with two items", cards) } } + +func TestMediaSeriesKeyCollapsesSpecialTitleSuffix(t *testing.T) { + main := model.Media{ + LibraryID: "lib-tv", + Path: `cloud://openlist/电视剧/欧美剧/Example Show/Season 01/Example.Show.S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + special := model.Media{ + LibraryID: "lib-tv", + Path: `cloud://openlist/电视剧/欧美剧/Example Show Specials/Example.Show.Special.01.mkv`, + SeasonNum: 0, + EpisodeNum: 1, + } + chineseSpecial := model.Media{ + LibraryID: "lib-tv", + Path: `cloud://openlist/动漫/国漫/示例剧 特别篇/示例剧.SP01.mkv`, + SeasonNum: 0, + EpisodeNum: 1, + } + chineseMain := model.Media{ + LibraryID: "lib-tv", + Path: `cloud://openlist/动漫/国漫/示例剧/Season 01/示例剧.S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + + if got, want := mediaSeriesKey(special), mediaSeriesKey(main); got != want { + t.Fatalf("english special key=%q, want main key=%q", got, want) + } + if got, want := mediaSeriesKey(chineseSpecial), mediaSeriesKey(chineseMain); got != want { + t.Fatalf("chinese special key=%q, want main key=%q", got, want) + } +} diff --git a/web/src/components/ManualScrapeDialog.tsx b/web/src/components/ManualScrapeDialog.tsx index 677e35c..68b7eb7 100644 --- a/web/src/components/ManualScrapeDialog.tsx +++ b/web/src/components/ManualScrapeDialog.tsx @@ -27,6 +27,8 @@ const providers = [ { value: 'adult', label: 'Adult / 番号' }, ] +type ProviderMode = 'all' | 'single' | 'multi' + export function ManualScrapeDialog({ open, media, @@ -39,6 +41,7 @@ export function ManualScrapeDialog({ onApplied, }: ManualScrapeDialogProps) { const [query, setQuery] = useState('') + const [providerMode, setProviderMode] = useState('all') const [selectedProviders, setSelectedProviders] = useState([]) const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false) const [searching, setSearching] = useState(false) @@ -53,6 +56,7 @@ export function ManualScrapeDialog({ useEffect(() => { if (!open) return setQuery(defaultQuery || media?.title || '') + setProviderMode('all') setSelectedProviders([]) setIncludeEpisodeArtwork(episodeArtwork ?? false) setItems([]) @@ -68,14 +72,34 @@ export function ManualScrapeDialog({ return } setSearching(true) + setItems([]) + const providerValues = manualSearchProvidersForMode(providerMode, selectedProviders) + if (providerValues.length === 0) { + toast.error('至少选择一个刮削源') + setSearching(false) + return + } try { - const results = await mediaAPI.manualScrapeSearch(media.id, { - query: text, - provider: selectedProviders.length > 0 ? selectedProviders.join(',') : 'all', - media_type: mediaType, - }) - setItems(results) - if (results.length === 0) toast.error('没有找到可用候选') + 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) @@ -121,30 +145,52 @@ export function ManualScrapeDialog({ -
-
- - {providers.map((item) => { - const active = selectedProviders.includes(item.value) - return ( - - ) - })} +
+
+
+ + + +
+ {providerMode !== 'all' && ( +
+ {providers.map((item) => { + const active = selectedProviders.includes(item.value) + return ( + + ) + })} +
+ )}
@@ -217,8 +263,32 @@ 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 toggleProvider(value: string, setSelectedProviders: Dispatch>) { +function manualSearchProvidersForMode(mode: ProviderMode, selectedProviders: string[]): string[] { + if (mode === 'all') return providers.map((provider) => provider.value) + if (mode === 'single') return [selectedProviders[0] || providers[0].value] + return selectedProviders +} + +function changeProviderMode( + mode: ProviderMode, + setProviderMode: Dispatch>, + setSelectedProviders: Dispatch>, +) { + setProviderMode(mode) setSelectedProviders((current) => { + if (mode === 'all') return [] + if (mode === 'single') return [current[0] || providers[0].value] + return current.length > 0 ? current : providers.slice(0, 4).map((provider) => provider.value) + }) +} + +function chooseProvider( + value: string, + mode: ProviderMode, + setSelectedProviders: Dispatch>, +) { + setSelectedProviders((current) => { + if (mode === 'single') return [value] if (current.includes(value)) { return current.filter((item) => item !== value) } @@ -226,6 +296,24 @@ function toggleProvider(value: string, setSelectedProviders: Dispatch [candidateKey(item), item])) + for (const item of incoming) { + byKey.set(candidateKey(item), item) + } + return Array.from(byKey.values()) +} + +function providerModeButtonClass(active: boolean): string { + return ( + 'inline-flex h-10 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 providerButtonClass(active: boolean): string { return ( 'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' + diff --git a/web/src/pages/DiscoverPage.tsx b/web/src/pages/DiscoverPage.tsx index a0a9efd..9611e1c 100644 --- a/web/src/pages/DiscoverPage.tsx +++ b/web/src/pages/DiscoverPage.tsx @@ -13,40 +13,52 @@ import { export function DiscoverPage() { const [sections, setSections] = useState([]) - const [selected, setSelected] = useState(defaultSections) + const [selected, setSelected] = useState([]) const [rows, setRows] = useState>({}) + const [rowLoading, setRowLoading] = useState>({}) const [rowErrors, setRowErrors] = useState>({}) - const [error, setError] = useState('') - const [loading, setLoading] = useState(true) + const [sectionsReady, setSectionsReady] = useState(false) + const [loading, setLoading] = useState(false) const [activeItem, setActiveItem] = useState(null) useEffect(() => { + let cancelled = false + setSectionsReady(false) discoverAPI .sections() .then((items) => { + if (cancelled) return setSections(items) const saved = readSavedSections(items) const available = new Set(items.map((item) => item.key)) const fallback = defaultSections.filter((key) => available.has(key)) setSelected(saved.length > 0 ? saved : fallback) + setSectionsReady(true) }) .catch(() => { + if (cancelled) return setSections(defaultSectionDefs) setSelected(defaultSections) + setSectionsReady(true) }) + return () => { + cancelled = true + } }, []) useEffect(() => { + if (!sectionsReady) return if (selected.length === 0) { setRows({}) + setRowLoading({}) setRowErrors({}) setLoading(false) return } let cancelled = false setLoading(true) - setError('') setRowErrors({}) + setRowLoading(Object.fromEntries(selected.map((key) => [key, true]))) setRows((current) => { const next: Record = {} for (const key of selected) { @@ -74,12 +86,17 @@ export function DiscoverPage() { setRows((current) => ({ ...current, [key]: [] })) setRowErrors((current) => ({ ...current, [key]: message })) }) - .finally(markDone) + .finally(() => { + if (!cancelled) { + setRowLoading((current) => ({ ...current, [key]: false })) + } + markDone() + }) } return () => { cancelled = true } - }, [selected]) + }, [sectionsReady, selected]) const sectionMap = useMemo( () => new Map(sections.map((section) => [section.key, section])), @@ -136,26 +153,24 @@ export function DiscoverPage() {
- {loading && !hasContent && } + {!sectionsReady && } - {!loading && error && ( -
- -

{error}

-
- )} - - {!loading && selected.length === 0 && ( + {sectionsReady && !loading && selected.length === 0 && (
至少选择一个推荐源,小宇宙才会开始转动。
)} - {!error && selected.length > 0 && (hasContent || !loading) && ( + {sectionsReady && selected.length > 0 && (
{selected.map((key) => { const items = rows[key] ?? [] - if (items.length === 0) return null + if (items.length === 0) { + if (rowLoading[key]) { + return + } + return null + } return ( )} - {!loading && !hasContent && ( + {!loading && !hasContent && !hasRowErrors && (

当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。 @@ -196,3 +211,16 @@ export function DiscoverPage() {

) } + +function DiscoverRowSkeleton({ title }: { title: string }) { + return ( +
+

{title}

+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((item) => ( +
+ ))} +
+
+ ) +} diff --git a/web/src/pages/SiteFormBasicFields.tsx b/web/src/pages/SiteFormBasicFields.tsx index 2ecc028..a693853 100644 --- a/web/src/pages/SiteFormBasicFields.tsx +++ b/web/src/pages/SiteFormBasicFields.tsx @@ -117,24 +117,5 @@ function SiteTypeGuide({ type }: { type: string }) { ); } - if (type === "yemapt") { - return ( -
-
- YemaPT 配置指南 -
-
-
- 认证方式:使用个人详情页创建的第三方对接专用 auth -
-
- 填入下方 API Key;后端会按 Wiki 要求通过 Authorization - 请求头原样发送。 -
-
-
- ); - } - return null; } diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index de817d4..6ec3453 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -113,6 +113,15 @@ function normalizeTitle(value?: string): string { .trim() } +const SERIES_SPECIAL_SUFFIX_RE = + /(?:\s+(?:specials?|sp|ova|oad|extra|extras)|\s*(?:特别篇|特別篇|番外|特典|外传|外傳))$/i + +function normalizePathSeriesTitle(value?: string): string { + const title = normalizeTitle(value) + const stripped = title.replace(SERIES_SPECIAL_SUFFIX_RE, '').trim() + return stripped || title +} + export function seriesTitleFromPath(path?: string): string { if (!path) return '' const parts = path.split(/[\\/]+/).filter(Boolean) @@ -122,7 +131,7 @@ export function seriesTitleFromPath(path?: string): string { dirIndex -= 1 } if (dirIndex < 0) return '' - return normalizeTitle(parts[dirIndex]) + return normalizePathSeriesTitle(parts[dirIndex]) } export function groupSeries(items: Media[] = []): SeriesCard[] {