From e56eed07587ccd0ab349b05ca895abf7a9c38d7d Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:56:35 +0800 Subject: [PATCH] fix: improve discover and manual scrape controls --- internal/service/media_series.go | 26 +++-- internal/service/media_series_test.go | 71 ++++++++++++ web/src/components/ManualScrapeDialog.tsx | 130 +++++++++------------- web/src/pages/DiscoverPage.tsx | 13 ++- web/src/utils/groupSeries.ts | 17 ++- 5 files changed, 163 insertions(+), 94 deletions(-) diff --git a/internal/service/media_series.go b/internal/service/media_series.go index a853d63..d181db1 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -169,11 +169,13 @@ 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._-]+`) - seriesSpecialSuffixRE = regexp.MustCompile(`(?i)(?:\s+(?:s0+|season 0+|special episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake)|\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._-]+`) + seriesSeasonDirRE = regexp.MustCompile(`(?i)^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$`) + seriesSpecialCodeRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$`) + seriesSpecialCJKRE = regexp.MustCompile(`(?i)\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$`) ) func normalizeSeriesTitle(value string) string { @@ -187,13 +189,23 @@ func normalizeSeriesTitle(value string) string { func normalizeSeriesPathTitle(value string) string { title := normalizeSeriesTitle(value) - stripped := strings.TrimSpace(seriesSpecialSuffixRE.ReplaceAllString(title, "")) + stripped := stripSeriesSpecialSuffix(title) if stripped != "" { return stripped } return title } +func stripSeriesSpecialSuffix(title string) string { + for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} { + stripped := strings.TrimSpace(re.ReplaceAllString(title, "")) + if stripped != "" && stripped != title { + return stripped + } + } + return title +} + func seriesTitleFromMediaPath(path string) string { if strings.TrimSpace(path) == "" { return "" @@ -203,7 +215,7 @@ func seriesTitleFromMediaPath(path string) string { return "" } dirIndex := len(parts) - 2 - for dirIndex >= 0 && strictSeasonFolderMatched(filepath.Base(parts[dirIndex])) { + for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) { dirIndex-- } if dirIndex < 0 { diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index c0adb5b..33661f8 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -99,3 +99,74 @@ func TestMediaSeriesKeyCollapsesSeasonZeroAndSpecialAliases(t *testing.T) { } } } + +func TestMediaSeriesKeyCollapsesNumberedSpecialSuffixes(t *testing.T) { + main := model.Media{ + LibraryID: "lib-tv", + Path: `F:\media\电视剧\欧美剧\Example Show\Season 01\Example Show - S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + chineseMain := model.Media{ + LibraryID: "lib-tv", + Path: `F:\media\电视剧\欧美剧\示例剧\Season 01\示例剧.S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + cases := map[string]struct { + item model.Media + want model.Media + }{ + "sp number": { + item: model.Media{ + LibraryID: "lib-tv", + Path: `F:\media\电视剧\欧美剧\Example Show SP01\Example Show.SP01.mkv`, + SeasonNum: 0, + EpisodeNum: 1, + }, + want: main, + }, + "ova number": { + item: model.Media{ + LibraryID: "lib-tv", + Path: `F:\media\电视剧\欧美剧\Example Show OVA 1\Example Show.OVA.1.mkv`, + SeasonNum: 0, + EpisodeNum: 1, + }, + want: main, + }, + "season zero episode": { + item: model.Media{ + LibraryID: "lib-tv", + Path: `F:\media\电视剧\欧美剧\Example Show S00E01\Example Show.S00E01.mkv`, + SeasonNum: 0, + EpisodeNum: 1, + }, + want: main, + }, + "wrapped special": { + item: model.Media{ + LibraryID: "lib-tv", + Path: `F:\media\电视剧\欧美剧\Example Show [Special]\Example Show.Special.mkv`, + SeasonNum: 0, + EpisodeNum: 1, + }, + want: main, + }, + "chinese numbered special": { + item: model.Media{ + LibraryID: "lib-tv", + Path: `F:\media\电视剧\欧美剧\示例剧 特别篇 第1集\示例剧.SP01.mkv`, + SeasonNum: 0, + EpisodeNum: 1, + }, + want: chineseMain, + }, + } + for name, tt := range cases { + want := mediaSeriesKey(tt.want) + if got := mediaSeriesKey(tt.item); got != want { + t.Fatalf("%s key=%q, want main key=%q", name, got, want) + } + } +} diff --git a/web/src/components/ManualScrapeDialog.tsx b/web/src/components/ManualScrapeDialog.tsx index 68b7eb7..5f015e3 100644 --- a/web/src/components/ManualScrapeDialog.tsx +++ b/web/src/components/ManualScrapeDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react' import toast from 'react-hot-toast' @@ -27,8 +27,6 @@ const providers = [ { value: 'adult', label: 'Adult / 番号' }, ] -type ProviderMode = 'all' | 'single' | 'multi' - export function ManualScrapeDialog({ open, media, @@ -41,7 +39,6 @@ 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) @@ -56,7 +53,6 @@ export function ManualScrapeDialog({ useEffect(() => { if (!open) return setQuery(defaultQuery || media?.title || '') - setProviderMode('all') setSelectedProviders([]) setIncludeEpisodeArtwork(episodeArtwork ?? false) setItems([]) @@ -73,7 +69,7 @@ export function ManualScrapeDialog({ } setSearching(true) setItems([]) - const providerValues = manualSearchProvidersForMode(providerMode, selectedProviders) + const providerValues = manualSearchProvidersForSelection(selectedProviders, text) if (providerValues.length === 0) { toast.error('至少选择一个刮削源') setSearching(false) @@ -146,51 +142,32 @@ 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 ( - - ) - })} -
- )}
@@ -263,37 +240,39 @@ 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 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 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 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 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 chooseProvider( - value: string, - mode: ProviderMode, - setSelectedProviders: Dispatch>, -) { - setSelectedProviders((current) => { - if (mode === 'single') return [value] - if (current.includes(value)) { - return current.filter((item) => item !== value) - } - return [...current, value] - }) +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[] { @@ -305,15 +284,6 @@ function mergeManualCandidates(current: ManualScrapeCandidate[], incoming: Manua 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 9611e1c..7a1dbcb 100644 --- a/web/src/pages/DiscoverPage.tsx +++ b/web/src/pages/DiscoverPage.tsx @@ -5,7 +5,6 @@ import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/dis import { ContentRow, DiscoverSkeleton } from './DiscoverContentRow' import { DiscoverDetailModal } from './DiscoverDetailModal' import { - defaultSectionDefs, defaultSections, discoverStorageKey, readSavedSections, @@ -37,8 +36,8 @@ export function DiscoverPage() { }) .catch(() => { if (cancelled) return - setSections(defaultSectionDefs) - setSelected(defaultSections) + setSections([]) + setSelected([]) setSectionsReady(true) }) return () => { @@ -48,6 +47,12 @@ export function DiscoverPage() { useEffect(() => { if (!sectionsReady) return + const available = new Set(sections.map((section) => section.key)) + const activeSelected = selected.filter((key) => available.has(key)) + if (activeSelected.length !== selected.length) { + setSelected(activeSelected) + return + } if (selected.length === 0) { setRows({}) setRowLoading({}) @@ -96,7 +101,7 @@ export function DiscoverPage() { return () => { cancelled = true } - }, [sectionsReady, selected]) + }, [sections, sectionsReady, selected]) const sectionMap = useMemo( () => new Map(sections.map((section) => [section.key, section])), diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 89f2e53..dbc8ff8 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -113,15 +113,26 @@ function normalizeTitle(value?: string): string { .trim() } -const SERIES_SPECIAL_SUFFIX_RE = - /(?:\s+(?:s0+|season 0+|special episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake)|\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇))$/i +const SERIES_SPECIAL_CODE_RE = + /\s*[\[((【]?\s*(?:s0+\s*e?\s*\d+|season\s*0+(?:\s*episode)?\s*\d*|special(?:\s*episode)?s?\s*\d*|sp\s*\d*|ovas?\s*\d*|oads?\s*\d*|extras?\s*\d*|bonus(?:es)?\s*\d*|omake\s*\d*)\s*[\]))】]?$/i + +const SERIES_SPECIAL_CJK_RE = + /\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$/i function normalizePathSeriesTitle(value?: string): string { const title = normalizeTitle(value) - const stripped = title.replace(SERIES_SPECIAL_SUFFIX_RE, '').trim() + const stripped = stripSeriesSpecialSuffix(title) return stripped || title } +function stripSeriesSpecialSuffix(title: string): string { + for (const pattern of [SERIES_SPECIAL_CODE_RE, SERIES_SPECIAL_CJK_RE]) { + const stripped = title.replace(pattern, '').trim() + if (stripped && stripped !== title) return stripped + } + return title +} + export function seriesTitleFromPath(path?: string): string { if (!path) return '' const parts = path.split(/[\\/]+/).filter(Boolean)