mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
fix: improve discover and manual scrape controls
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ' +
|
||||||
|
|||||||
@@ -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])),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user