fix: improve discover and manual scrape controls

This commit is contained in:
ShukeBta
2026-06-24 15:56:35 +08:00
parent c212207f74
commit e56eed0758
5 changed files with 163 additions and 94 deletions
+19 -7
View File
@@ -169,11 +169,13 @@ func compactSeriesKey(raw string) string {
} }
var ( var (
seriesYearRE = regexp.MustCompile(`\s*\((?:19|20)\d{2}\)\s*`) seriesYearRE = regexp.MustCompile(`\s*\((?:19|20)\d{2}\)\s*`)
seriesIDRE = regexp.MustCompile(`(?i)\s*\[(?:tmdb|tmdbid)[=-]\d+\]\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*`) seriesBraceRE = regexp.MustCompile(`(?i)\s*\{(?:tmdb|tmdbid|douban|bangumi|bgm|thetvdb|tvdb)[\s:=#-]*[a-z0-9_-]+\}\s*`)
seriesSpacerRE = regexp.MustCompile(`[\s._-]+`) seriesSpacerRE = regexp.MustCompile(`[\s._-]+`)
seriesSpecialSuffixRE = regexp.MustCompile(`(?i)(?:\s+(?:s0+|season 0+|special episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake)|\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 { func normalizeSeriesTitle(value string) string {
@@ -187,13 +189,23 @@ func normalizeSeriesTitle(value string) string {
func normalizeSeriesPathTitle(value string) string { func normalizeSeriesPathTitle(value string) string {
title := normalizeSeriesTitle(value) title := normalizeSeriesTitle(value)
stripped := strings.TrimSpace(seriesSpecialSuffixRE.ReplaceAllString(title, "")) stripped := stripSeriesSpecialSuffix(title)
if stripped != "" { if stripped != "" {
return stripped return stripped
} }
return title 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 { func seriesTitleFromMediaPath(path string) string {
if strings.TrimSpace(path) == "" { if strings.TrimSpace(path) == "" {
return "" return ""
@@ -203,7 +215,7 @@ func seriesTitleFromMediaPath(path string) string {
return "" return ""
} }
dirIndex := len(parts) - 2 dirIndex := len(parts) - 2
for dirIndex >= 0 && strictSeasonFolderMatched(filepath.Base(parts[dirIndex])) { for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) {
dirIndex-- dirIndex--
} }
if dirIndex < 0 { if dirIndex < 0 {
+71
View File
@@ -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)
}
}
}
+50 -80
View File
@@ -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 { Check, LoaderCircle, Search, Sparkles, X } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@@ -27,8 +27,6 @@ const providers = [
{ value: 'adult', label: 'Adult / 番号' }, { value: 'adult', label: 'Adult / 番号' },
] ]
type ProviderMode = 'all' | 'single' | 'multi'
export function ManualScrapeDialog({ export function ManualScrapeDialog({
open, open,
media, media,
@@ -41,7 +39,6 @@ export function ManualScrapeDialog({
onApplied, onApplied,
}: ManualScrapeDialogProps) { }: ManualScrapeDialogProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [providerMode, setProviderMode] = useState<ProviderMode>('all')
const [selectedProviders, setSelectedProviders] = useState<string[]>([]) const [selectedProviders, setSelectedProviders] = useState<string[]>([])
const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false) const [includeEpisodeArtwork, setIncludeEpisodeArtwork] = useState(false)
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
@@ -56,7 +53,6 @@ export function ManualScrapeDialog({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setQuery(defaultQuery || media?.title || '') setQuery(defaultQuery || media?.title || '')
setProviderMode('all')
setSelectedProviders([]) setSelectedProviders([])
setIncludeEpisodeArtwork(episodeArtwork ?? false) setIncludeEpisodeArtwork(episodeArtwork ?? false)
setItems([]) setItems([])
@@ -73,7 +69,7 @@ export function ManualScrapeDialog({
} }
setSearching(true) setSearching(true)
setItems([]) setItems([])
const providerValues = manualSearchProvidersForMode(providerMode, selectedProviders) const providerValues = manualSearchProvidersForSelection(selectedProviders, text)
if (providerValues.length === 0) { if (providerValues.length === 0) {
toast.error('至少选择一个刮削源') toast.error('至少选择一个刮削源')
setSearching(false) setSearching(false)
@@ -146,51 +142,32 @@ export function ManualScrapeDialog({
</div> </div>
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 lg:flex-row"> <div className="flex flex-col gap-3 border-b border-sand-200 p-5 lg:flex-row">
<div className="flex min-w-0 flex-col gap-2 lg:max-w-sm"> <div className="flex min-w-0 flex-col gap-2 lg:max-w-md">
<span className="text-xs font-bold text-sand-500">刮削源</span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
type="button" type="button"
onClick={() => changeProviderMode('all', setProviderMode, setSelectedProviders)} onClick={() => setSelectedProviders([])}
className={providerModeButtonClass(providerMode === 'all')} className={providerButtonClass(selectedProviders.length === 0)}
> >
{providerMode === 'all' && <Check size={13} />} {selectedProviders.length === 0 && <Check size={13} />}
全部源 全部源
</button> </button>
<button {providers.map((item) => {
type="button" const active = selectedProviders.includes(item.value)
onClick={() => changeProviderMode('single', setProviderMode, setSelectedProviders)} return (
className={providerModeButtonClass(providerMode === 'single')} <button
> key={item.value}
{providerMode === 'single' && <Check size={13} />} type="button"
单源 onClick={() => setSelectedProviders((current) => toggleProvider(current, item.value))}
</button> className={providerButtonClass(active)}
<button >
type="button" {active && <Check size={13} />}
onClick={() => changeProviderMode('multi', setProviderMode, setSelectedProviders)} {item.label}
className={providerModeButtonClass(providerMode === 'multi')} </button>
> )
{providerMode === 'multi' && <Check size={13} />} })}
多源
</button>
</div> </div>
{providerMode !== 'all' && (
<div className="flex flex-wrap gap-2">
{providers.map((item) => {
const active = selectedProviders.includes(item.value)
return (
<button
key={item.value}
type="button"
onClick={() => chooseProvider(item.value, providerMode, setSelectedProviders)}
className={providerButtonClass(active)}
>
{active && <Check size={13} />}
{item.label}
</button>
)
})}
</div>
)}
</div> </div>
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
@@ -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 || ''}` 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[] { function manualSearchProvidersForSelection(selectedProviders: string[], query: string): string[] {
if (mode === 'all') return providers.map((provider) => provider.value) const explicitProvider = providerFromQueryPrefix(query)
if (mode === 'single') return [selectedProviders[0] || providers[0].value] if (explicitProvider) return [explicitProvider]
return selectedProviders return selectedProviders.length > 0 ? selectedProviders : providers.map((provider) => provider.value)
} }
function changeProviderMode( function toggleProvider(current: string[], value: string): string[] {
mode: ProviderMode, if (current.includes(value)) {
setProviderMode: Dispatch<SetStateAction<ProviderMode>>, return current.filter((item) => item !== value)
setSelectedProviders: Dispatch<SetStateAction<string[]>>, }
) { const next = [...current, value]
setProviderMode(mode) return next.length === providers.length ? [] : next
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( function providerFromQueryPrefix(query: string): string {
value: string, const prefix = query.trim().toLowerCase().match(/^([a-z]+)\s*:/)?.[1]
mode: ProviderMode, switch (prefix) {
setSelectedProviders: Dispatch<SetStateAction<string[]>>, case 'tmdb':
) { return 'tmdb'
setSelectedProviders((current) => { case 'douban':
if (mode === 'single') return [value] return 'douban'
if (current.includes(value)) { case 'bangumi':
return current.filter((item) => item !== value) case 'bgm':
} return 'bangumi'
return [...current, value] case 'thetvdb':
}) case 'tvdb':
return 'thetvdb'
case 'adult':
case 'jav':
return 'adult'
default:
return ''
}
} }
function mergeManualCandidates(current: ManualScrapeCandidate[], incoming: ManualScrapeCandidate[]): ManualScrapeCandidate[] { function mergeManualCandidates(current: ManualScrapeCandidate[], incoming: ManualScrapeCandidate[]): ManualScrapeCandidate[] {
@@ -305,15 +284,6 @@ function mergeManualCandidates(current: ManualScrapeCandidate[], incoming: Manua
return Array.from(byKey.values()) 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 { function providerButtonClass(active: boolean): string {
return ( return (
'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' + 'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' +
+9 -4
View File
@@ -5,7 +5,6 @@ import { discoverAPI, type DiscoverItem, type DiscoverSection } from '../api/dis
import { ContentRow, DiscoverSkeleton } from './DiscoverContentRow' import { ContentRow, DiscoverSkeleton } from './DiscoverContentRow'
import { DiscoverDetailModal } from './DiscoverDetailModal' import { DiscoverDetailModal } from './DiscoverDetailModal'
import { import {
defaultSectionDefs,
defaultSections, defaultSections,
discoverStorageKey, discoverStorageKey,
readSavedSections, readSavedSections,
@@ -37,8 +36,8 @@ export function DiscoverPage() {
}) })
.catch(() => { .catch(() => {
if (cancelled) return if (cancelled) return
setSections(defaultSectionDefs) setSections([])
setSelected(defaultSections) setSelected([])
setSectionsReady(true) setSectionsReady(true)
}) })
return () => { return () => {
@@ -48,6 +47,12 @@ export function DiscoverPage() {
useEffect(() => { useEffect(() => {
if (!sectionsReady) return 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) { if (selected.length === 0) {
setRows({}) setRows({})
setRowLoading({}) setRowLoading({})
@@ -96,7 +101,7 @@ export function DiscoverPage() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [sectionsReady, selected]) }, [sections, sectionsReady, selected])
const sectionMap = useMemo( const sectionMap = useMemo(
() => new Map(sections.map((section) => [section.key, section])), () => new Map(sections.map((section) => [section.key, section])),
+14 -3
View File
@@ -113,15 +113,26 @@ function normalizeTitle(value?: string): string {
.trim() .trim()
} }
const SERIES_SPECIAL_SUFFIX_RE = const SERIES_SPECIAL_CODE_RE =
/(?:\s+(?:s0+|season 0+|special episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake)|\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇))$/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*[\]))】]?$/i
const SERIES_SPECIAL_CJK_RE =
/\s*[\[((【]?\s*(?:特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)(?:\s*第?\s*[0-9一二三四五六七八九十百零两]+(?:[集话話期])?)?\s*[\]))】]?$/i
function normalizePathSeriesTitle(value?: string): string { function normalizePathSeriesTitle(value?: string): string {
const title = normalizeTitle(value) const title = normalizeTitle(value)
const stripped = title.replace(SERIES_SPECIAL_SUFFIX_RE, '').trim() const stripped = stripSeriesSpecialSuffix(title)
return stripped || 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 { export function seriesTitleFromPath(path?: string): string {
if (!path) return '' if (!path) return ''
const parts = path.split(/[\\/]+/).filter(Boolean) const parts = path.split(/[\\/]+/).filter(Boolean)