mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
fix: improve discover and manual scrape flows
This commit is contained in:
@@ -399,8 +399,8 @@ func (s *ScraperService) manualDoubanMatch(ctx context.Context, query string) *M
|
|||||||
if s.douban == nil || !s.douban.Enabled() {
|
if s.douban == nil || !s.douban.Enabled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if _, ok := parsePositiveInt(query); ok {
|
if id, ok := parsePositiveIDString(query); ok {
|
||||||
if match, err := s.douban.GetMatchByID(ctx, query); err == nil && match != nil {
|
if match, err := s.douban.GetMatchByID(ctx, id); err == nil && match != nil {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,8 +431,8 @@ func (s *ScraperService) manualTheTVDBMatch(ctx context.Context, query string) *
|
|||||||
if s.thetvdb == nil || !s.thetvdb.Enabled() {
|
if s.thetvdb == nil || !s.thetvdb.Enabled() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if _, ok := parsePositiveInt(normalizeTheTVDBSeriesID(query)); ok {
|
if id, ok := parsePositiveIDString(normalizeTheTVDBSeriesID(query)); ok {
|
||||||
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, query); err == nil && match != nil {
|
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, id); err == nil && match != nil {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -508,6 +508,14 @@ func parsePositiveInt(value string) (int, bool) {
|
|||||||
return id, err == nil && id > 0
|
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 {
|
func manualScrapeBatchName(ids []string) string {
|
||||||
if len(ids) == 1 {
|
if len(ids) == 1 {
|
||||||
return ids[0]
|
return ids[0]
|
||||||
|
|||||||
@@ -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) {
|
func TestManualSearchReturnsTMDbCandidatePage(t *testing.T) {
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -169,10 +169,11 @@ 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+(?:specials?|sp|ova|oad|extra|extras)|\s*(?:特别篇|特別篇|番外|特典|外传|外傳))$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func normalizeSeriesTitle(value string) string {
|
func normalizeSeriesTitle(value string) string {
|
||||||
@@ -184,6 +185,15 @@ func normalizeSeriesTitle(value string) string {
|
|||||||
return strings.TrimSpace(value)
|
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 {
|
func seriesTitleFromMediaPath(path string) string {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -199,7 +209,7 @@ func seriesTitleFromMediaPath(path string) string {
|
|||||||
if dirIndex < 0 {
|
if dirIndex < 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return normalizeSeriesTitle(parts[dirIndex])
|
return normalizeSeriesPathTitle(parts[dirIndex])
|
||||||
}
|
}
|
||||||
|
|
||||||
func seriesDisplayTitle(media model.Media) string {
|
func seriesDisplayTitle(media model.Media) string {
|
||||||
|
|||||||
@@ -27,3 +27,37 @@ func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
|
|||||||
t.Fatalf("cards=%#v, want one merged series card with two items", cards)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ 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,
|
||||||
@@ -39,6 +41,7 @@ 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)
|
||||||
@@ -53,6 +56,7 @@ 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([])
|
||||||
@@ -68,14 +72,34 @@ export function ManualScrapeDialog({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
|
setItems([])
|
||||||
|
const providerValues = manualSearchProvidersForMode(providerMode, selectedProviders)
|
||||||
|
if (providerValues.length === 0) {
|
||||||
|
toast.error('至少选择一个刮削源')
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const results = await mediaAPI.manualScrapeSearch(media.id, {
|
const settled = await Promise.allSettled(
|
||||||
query: text,
|
providerValues.map(async (provider) => {
|
||||||
provider: selectedProviders.length > 0 ? selectedProviders.join(',') : 'all',
|
const results = await mediaAPI.manualScrapeSearch(media.id, {
|
||||||
media_type: mediaType,
|
query: text,
|
||||||
})
|
provider,
|
||||||
setItems(results)
|
media_type: mediaType,
|
||||||
if (results.length === 0) toast.error('没有找到可用候选')
|
})
|
||||||
|
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) {
|
} catch (err: unknown) {
|
||||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '搜索失败'
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '搜索失败'
|
||||||
toast.error(msg)
|
toast.error(msg)
|
||||||
@@ -121,30 +145,52 @@ export function ManualScrapeDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 sm: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-wrap gap-2">
|
<div className="flex min-w-0 flex-col gap-2 lg:max-w-sm">
|
||||||
<button
|
<div className="flex flex-wrap gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSelectedProviders([])}
|
type="button"
|
||||||
className={providerButtonClass(selectedProviders.length === 0)}
|
onClick={() => changeProviderMode('all', setProviderMode, setSelectedProviders)}
|
||||||
>
|
className={providerModeButtonClass(providerMode === 'all')}
|
||||||
{selectedProviders.length === 0 && <Check size={13} />}
|
>
|
||||||
全部源
|
{providerMode === 'all' && <Check size={13} />}
|
||||||
</button>
|
全部源
|
||||||
{providers.map((item) => {
|
</button>
|
||||||
const active = selectedProviders.includes(item.value)
|
<button
|
||||||
return (
|
type="button"
|
||||||
<button
|
onClick={() => changeProviderMode('single', setProviderMode, setSelectedProviders)}
|
||||||
key={item.value}
|
className={providerModeButtonClass(providerMode === 'single')}
|
||||||
type="button"
|
>
|
||||||
onClick={() => toggleProvider(item.value, setSelectedProviders)}
|
{providerMode === 'single' && <Check size={13} />}
|
||||||
className={providerButtonClass(active)}
|
单源
|
||||||
>
|
</button>
|
||||||
{active && <Check size={13} />}
|
<button
|
||||||
{item.label}
|
type="button"
|
||||||
</button>
|
onClick={() => changeProviderMode('multi', setProviderMode, setSelectedProviders)}
|
||||||
)
|
className={providerModeButtonClass(providerMode === 'multi')}
|
||||||
})}
|
>
|
||||||
|
{providerMode === 'multi' && <Check size={13} />}
|
||||||
|
多源
|
||||||
|
</button>
|
||||||
|
</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" />
|
||||||
@@ -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 || ''}`
|
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<SetStateAction<string[]>>) {
|
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<SetStateAction<ProviderMode>>,
|
||||||
|
setSelectedProviders: Dispatch<SetStateAction<string[]>>,
|
||||||
|
) {
|
||||||
|
setProviderMode(mode)
|
||||||
setSelectedProviders((current) => {
|
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<SetStateAction<string[]>>,
|
||||||
|
) {
|
||||||
|
setSelectedProviders((current) => {
|
||||||
|
if (mode === 'single') return [value]
|
||||||
if (current.includes(value)) {
|
if (current.includes(value)) {
|
||||||
return current.filter((item) => item !== value)
|
return current.filter((item) => item !== value)
|
||||||
}
|
}
|
||||||
@@ -226,6 +296,24 @@ function toggleProvider(value: string, setSelectedProviders: Dispatch<SetStateAc
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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 ' +
|
||||||
|
|||||||
@@ -13,40 +13,52 @@ import {
|
|||||||
|
|
||||||
export function DiscoverPage() {
|
export function DiscoverPage() {
|
||||||
const [sections, setSections] = useState<DiscoverSection[]>([])
|
const [sections, setSections] = useState<DiscoverSection[]>([])
|
||||||
const [selected, setSelected] = useState<string[]>(defaultSections)
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
|
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
|
||||||
|
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({})
|
||||||
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
|
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
|
||||||
const [error, setError] = useState('')
|
const [sectionsReady, setSectionsReady] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(false)
|
||||||
const [activeItem, setActiveItem] = useState<DiscoverItem | null>(null)
|
const [activeItem, setActiveItem] = useState<DiscoverItem | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setSectionsReady(false)
|
||||||
discoverAPI
|
discoverAPI
|
||||||
.sections()
|
.sections()
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
|
if (cancelled) return
|
||||||
setSections(items)
|
setSections(items)
|
||||||
const saved = readSavedSections(items)
|
const saved = readSavedSections(items)
|
||||||
const available = new Set(items.map((item) => item.key))
|
const available = new Set(items.map((item) => item.key))
|
||||||
const fallback = defaultSections.filter((key) => available.has(key))
|
const fallback = defaultSections.filter((key) => available.has(key))
|
||||||
setSelected(saved.length > 0 ? saved : fallback)
|
setSelected(saved.length > 0 ? saved : fallback)
|
||||||
|
setSectionsReady(true)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
setSections(defaultSectionDefs)
|
setSections(defaultSectionDefs)
|
||||||
setSelected(defaultSections)
|
setSelected(defaultSections)
|
||||||
|
setSectionsReady(true)
|
||||||
})
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!sectionsReady) return
|
||||||
if (selected.length === 0) {
|
if (selected.length === 0) {
|
||||||
setRows({})
|
setRows({})
|
||||||
|
setRowLoading({})
|
||||||
setRowErrors({})
|
setRowErrors({})
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
|
||||||
setRowErrors({})
|
setRowErrors({})
|
||||||
|
setRowLoading(Object.fromEntries(selected.map((key) => [key, true])))
|
||||||
setRows((current) => {
|
setRows((current) => {
|
||||||
const next: Record<string, DiscoverItem[]> = {}
|
const next: Record<string, DiscoverItem[]> = {}
|
||||||
for (const key of selected) {
|
for (const key of selected) {
|
||||||
@@ -74,12 +86,17 @@ export function DiscoverPage() {
|
|||||||
setRows((current) => ({ ...current, [key]: [] }))
|
setRows((current) => ({ ...current, [key]: [] }))
|
||||||
setRowErrors((current) => ({ ...current, [key]: message }))
|
setRowErrors((current) => ({ ...current, [key]: message }))
|
||||||
})
|
})
|
||||||
.finally(markDone)
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRowLoading((current) => ({ ...current, [key]: false }))
|
||||||
|
}
|
||||||
|
markDone()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [selected])
|
}, [sectionsReady, selected])
|
||||||
|
|
||||||
const sectionMap = useMemo(
|
const sectionMap = useMemo(
|
||||||
() => new Map(sections.map((section) => [section.key, section])),
|
() => new Map(sections.map((section) => [section.key, section])),
|
||||||
@@ -136,26 +153,24 @@ export function DiscoverPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{loading && !hasContent && <DiscoverSkeleton />}
|
{!sectionsReady && <DiscoverSkeleton />}
|
||||||
|
|
||||||
{!loading && error && (
|
{sectionsReady && !loading && selected.length === 0 && (
|
||||||
<div className="flex items-center gap-3 rounded-2xl border border-red-500/20 bg-red-500/10 p-4">
|
|
||||||
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-red-400" />
|
|
||||||
<p className="text-red-300">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && selected.length === 0 && (
|
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center text-sand-500">
|
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center text-sand-500">
|
||||||
至少选择一个推荐源,小宇宙才会开始转动。
|
至少选择一个推荐源,小宇宙才会开始转动。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && selected.length > 0 && (hasContent || !loading) && (
|
{sectionsReady && selected.length > 0 && (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{selected.map((key) => {
|
{selected.map((key) => {
|
||||||
const items = rows[key] ?? []
|
const items = rows[key] ?? []
|
||||||
if (items.length === 0) return null
|
if (items.length === 0) {
|
||||||
|
if (rowLoading[key]) {
|
||||||
|
return <DiscoverRowSkeleton key={key} title={sectionMap.get(key)?.label ?? key} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ContentRow
|
<ContentRow
|
||||||
key={key}
|
key={key}
|
||||||
@@ -177,7 +192,7 @@ export function DiscoverPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !hasContent && (
|
{!loading && !hasContent && !hasRowErrors && (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
|
<div className="rounded-2xl border border-gray-200 bg-white p-10 text-center">
|
||||||
<p className="text-sand-500">
|
<p className="text-sand-500">
|
||||||
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
|
当前选择的推荐源暂未返回内容,可切换豆瓣 / Bangumi 或检查网络代理。
|
||||||
@@ -196,3 +211,16 @@ export function DiscoverPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiscoverRowSkeleton({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8].map((item) => (
|
||||||
|
<div key={item} className="aspect-[2/3] animate-pulse rounded-xl bg-gray-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -117,24 +117,5 @@ function SiteTypeGuide({ type }: { type: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "yemapt") {
|
|
||||||
return (
|
|
||||||
<div className="p-3 rounded-xl border border-cyan-500/30 bg-cyan-500/5">
|
|
||||||
<div className="text-sm font-medium text-cyan-400 mb-1">
|
|
||||||
YemaPT 配置指南
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-ink-50 space-y-1">
|
|
||||||
<div>
|
|
||||||
<b>认证方式:</b>使用个人详情页创建的第三方对接专用 auth
|
|
||||||
</div>
|
|
||||||
<div className="pl-3 text-sand-500">
|
|
||||||
填入下方 API Key;后端会按 Wiki 要求通过 Authorization
|
|
||||||
请求头原样发送。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ function normalizeTitle(value?: string): string {
|
|||||||
.trim()
|
.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 {
|
export function seriesTitleFromPath(path?: string): string {
|
||||||
if (!path) return ''
|
if (!path) return ''
|
||||||
const parts = path.split(/[\\/]+/).filter(Boolean)
|
const parts = path.split(/[\\/]+/).filter(Boolean)
|
||||||
@@ -122,7 +131,7 @@ export function seriesTitleFromPath(path?: string): string {
|
|||||||
dirIndex -= 1
|
dirIndex -= 1
|
||||||
}
|
}
|
||||||
if (dirIndex < 0) return ''
|
if (dirIndex < 0) return ''
|
||||||
return normalizeTitle(parts[dirIndex])
|
return normalizePathSeriesTitle(parts[dirIndex])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function groupSeries(items: Media[] = []): SeriesCard[] {
|
export function groupSeries(items: Media[] = []): SeriesCard[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user