This commit is contained in:
truewhile
2026-09-09 19:26:22 +08:00
parent f9dd082159
commit 62206aa05a
13 changed files with 222 additions and 25 deletions
+7
View File
@@ -24,6 +24,13 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
mediaType = lib.Type mediaType = lib.Type
} }
} }
// Keep manual scraping consistent with automatic scraping for theatrical
// features stored inside anime libraries. The UI may pass the library type
// ("anime"), which otherwise makes TMDb stop after a TV result and hide the
// actual movie candidate.
if mediaLooksLikeTheatricalFeature(media) {
mediaType = "movie"
}
mediaType = normalizeMediaType(mediaType, queries[0], "") mediaType = normalizeMediaType(mediaType, queries[0], "")
providers := manualSearchProviderSet(provider) providers := manualSearchProviderSet(provider)
year := mediaYearHint(media) year := mediaYearHint(media)
+74
View File
@@ -313,6 +313,80 @@ func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
} }
} }
func TestManualSearchAnimeTheatricalPrefersTMDbMovie(t *testing.T) {
var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/search/movie":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 635302,
"title": "鬼灭之刃 剧场版 无限列车篇",
"original_title": "劇場版「鬼滅の刃」無限列車編",
"release_date": "2020-10-16",
}},
})
case "/search/tv":
_ = json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{{
"id": 85937,
"name": "鬼灭之刃",
"original_name": "鬼滅の刃",
"first_air_date": "2019-04-06",
}},
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.TMDbAPIKey = "test-key"
cfg.Secrets.TMDbAPIProxy = upstream.URL
log := zap.NewNop()
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "鬼灭之刃 剧场版 无限列车篇",
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
results, err := scraper.ManualSearch(t.Context(), &media, media.Title, "tmdb", "anime")
if err != nil {
t.Fatal(err)
}
if len(results) == 0 || results[0].TMDbID != 635302 || results[0].MediaType != "movie" {
t.Fatalf("manual theatrical results=%#v, paths=%v", results, paths)
}
if len(paths) == 0 || paths[0] != "/search/movie" {
t.Fatalf("TMDb search paths=%v, want movie first", paths)
}
for _, path := range paths {
if path == "/search/tv" {
t.Fatalf("manual theatrical search unexpectedly queried TV after finding movie: paths=%v", paths)
}
}
}
func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) { func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) {
var paths []string var paths []string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -173,7 +173,7 @@ func seriesTitleFromMediaPath(path string) string {
if last := parts[len(parts)-1]; !seriesPathPartLooksLikeFile(last) && !seriesSeasonDirRE.MatchString(filepath.Base(last)) { if last := parts[len(parts)-1]; !seriesPathPartLooksLikeFile(last) && !seriesSeasonDirRE.MatchString(filepath.Base(last)) {
dirIndex = len(parts) - 1 dirIndex = len(parts) - 1
} }
for dirIndex >= 0 && seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) { for dirIndex >= 0 && (seriesSeasonDirRE.MatchString(filepath.Base(parts[dirIndex])) || isTheatricalFolder(parts[dirIndex])) {
dirIndex-- dirIndex--
} }
if dirIndex < 0 { if dirIndex < 0 {
+27
View File
@@ -404,6 +404,33 @@ func TestGroupMediaSeriesCardsKeepsMovieVersionsAsOneMovie(t *testing.T) {
} }
} }
func TestGroupMediaSeriesCardsKeepsTheatricalMovieWithTVSeries(t *testing.T) {
episode := model.Media{
Base: model.Base{ID: "episode"},
LibraryID: "anime",
Title: "超人高校生们",
Path: `/media/动漫/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
theatrical := model.Media{
Base: model.Base{ID: "theatrical"},
LibraryID: "anime",
Title: "超人高校生们",
Path: `/media/动漫/超人高校生们/剧场版/超人高校生们 剧场版.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
cards := groupMediaSeriesCards([]model.Media{episode, theatrical})
if len(cards) != 1 {
t.Fatalf("cards=%#v, want theatrical movie retained with TV series", cards)
}
if cards[0].Count != 2 {
t.Fatalf("series card count=%d, want TV episode plus theatrical movie", cards[0].Count)
}
}
func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) { func TestGroupMediaSeriesCardsDoesNotCollideMovieAndTVExternalIDs(t *testing.T) {
movie := model.Media{ movie := model.Media{
Base: model.Base{ID: "movie"}, Base: model.Base{ID: "movie"},
@@ -66,6 +66,28 @@ func TestMetadataMatchCompatibilityForTheatricalFeatures(t *testing.T) {
} }
} }
func TestMediaLooksLikeTheatricalFeatureIgnoresStaleEpisodeIdentity(t *testing.T) {
media := &model.Media{
Title: "未命名",
Path: `/media/anime/超人高校生们/剧场版/超人高校生们 剧场版.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if !mediaLooksLikeTheatricalFeature(media) {
t.Fatal("theatrical path should override stale persisted season/episode fields")
}
realEpisode := &model.Media{
Title: "剧场版制作幕后",
Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if mediaLooksLikeTheatricalFeature(realEpisode) {
t.Fatal("an actual episode marker in the path must remain episodic")
}
}
func TestScrapeQueryCandidatesForAnimeTheatricalMix(t *testing.T) { func TestScrapeQueryCandidatesForAnimeTheatricalMix(t *testing.T) {
lib := &model.Library{ lib := &model.Library{
Path: `/media/anime`, Path: `/media/anime`,
+4 -3
View File
@@ -40,9 +40,10 @@ func mediaLooksLikeTheatricalFeature(m *model.Media) bool {
if m == nil { if m == nil {
return false return false
} }
if m.SeasonNum > 0 || m.EpisodeNum > 0 { // Trust an episode marker that is actually present in the path, but do not
return false // trust persisted season/episode fields here. Older scans could incorrectly
} // assign those fields to a theatrical file, which would permanently prevent
// both manual re-scraping and separation from the TV series.
if season, ep := ParseEpisode(m.Path); season > 0 || ep > 0 { if season, ep := ParseEpisode(m.Path); season > 0 || ep > 0 {
return false return false
} }
+6 -1
View File
@@ -5,7 +5,7 @@ import { motion } from 'framer-motion'
import { historyAPI } from '../api/history' import { historyAPI } from '../api/history'
import type { Media } from '../types' import type { Media } from '../types'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import type { SeriesCard } from '../utils/groupSeries' import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
import { import {
sortMediaList, sortMediaList,
sortSeriesList, sortSeriesList,
@@ -150,6 +150,9 @@ export function LibraryPage() {
setSelectedSeason, setSelectedSeason,
onClearSeriesState: () => setSeriesMetadataEditOpen(false), onClearSeriesState: () => setSeriesMetadataEditOpen(false),
}) })
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
?? selectedSeries?.rep
?? null
const { const {
scraping, scraping,
@@ -287,6 +290,7 @@ export function LibraryPage() {
onOrganize={handleSeriesOrganize} onOrganize={handleSeriesOrganize}
onDelete={handleSeriesDelete} onDelete={handleSeriesDelete}
onSeasonChange={setSelectedSeason} onSeasonChange={setSelectedSeason}
onManualScrapeMedia={setManualMovie}
/> />
<LibraryPageDialogs <LibraryPageDialogs
@@ -296,6 +300,7 @@ export function LibraryPage() {
seriesMetadataEditOpen={seriesMetadataEditOpen} seriesMetadataEditOpen={seriesMetadataEditOpen}
manualMovie={manualMovie} manualMovie={manualMovie}
selectedSeries={selectedSeries} selectedSeries={selectedSeries}
selectedSeriesScrapeMedia={selectedSeriesScrapeMedia}
selectedSeriesMediaIDs={selectedSeriesMediaIDs} selectedSeriesMediaIDs={selectedSeriesMediaIDs}
libraryType={library?.type} libraryType={library?.type}
scrapeEpisodeArtwork={scrapeEpisodeArtwork} scrapeEpisodeArtwork={scrapeEpisodeArtwork}
+8 -3
View File
@@ -2,7 +2,7 @@ import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog' import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { ScrapeMetadataDialog } from '../components/ScrapeMetadataDialog' import { ScrapeMetadataDialog } from '../components/ScrapeMetadataDialog'
import type { Library, Media } from '../types' import type { Library, Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries' import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
type LibraryPageDialogsProps = { type LibraryPageDialogsProps = {
scrapeDialogOpen: boolean scrapeDialogOpen: boolean
@@ -11,6 +11,7 @@ type LibraryPageDialogsProps = {
seriesMetadataEditOpen: boolean seriesMetadataEditOpen: boolean
manualMovie: Media | null manualMovie: Media | null
selectedSeries: SeriesCard | null selectedSeries: SeriesCard | null
selectedSeriesScrapeMedia: Media | null
selectedSeriesMediaIDs: string[] selectedSeriesMediaIDs: string[]
libraryType?: string libraryType?: string
scrapeEpisodeArtwork: boolean scrapeEpisodeArtwork: boolean
@@ -29,6 +30,7 @@ export function LibraryPageDialogs({
seriesMetadataEditOpen, seriesMetadataEditOpen,
manualMovie, manualMovie,
selectedSeries, selectedSeries,
selectedSeriesScrapeMedia,
selectedSeriesMediaIDs, selectedSeriesMediaIDs,
libraryType, libraryType,
scrapeEpisodeArtwork, scrapeEpisodeArtwork,
@@ -53,10 +55,10 @@ export function LibraryPageDialogs({
/> />
<ManualScrapeDialog <ManualScrapeDialog
open={manualSeriesScrapeOpen} open={manualSeriesScrapeOpen}
media={selectedSeries?.rep ?? null} media={selectedSeriesScrapeMedia}
mediaIds={selectedSeriesMediaIDs} mediaIds={selectedSeriesMediaIDs}
defaultQuery={selectedSeriesTitle} defaultQuery={selectedSeriesTitle}
mediaType={selectedSeries ? scrapeMediaType(libraryType, selectedSeries.rep) : 'tv'} mediaType="tv"
scopeLabel={selectedSeriesTitle || '当前剧集'} scopeLabel={selectedSeriesTitle || '当前剧集'}
episodeArtwork={scrapeEpisodeArtwork} episodeArtwork={scrapeEpisodeArtwork}
onClose={onCloseManualSeriesScrape} onClose={onCloseManualSeriesScrape}
@@ -86,6 +88,9 @@ export function LibraryPageDialogs({
} }
function scrapeMediaType(libraryType: string | undefined, media: Media): string { function scrapeMediaType(libraryType: string | undefined, media: Media): string {
if (isTheatricalFeature(media)) {
return 'movie'
}
if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) { if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) {
return 'tv' return 'tv'
} }
+8 -6
View File
@@ -6,11 +6,10 @@ import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton' import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import type { Media } from '../types' import type { Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries' import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
type LibrarySeriesDetailHeaderProps = { type LibrarySeriesDetailHeaderProps = {
series: SeriesCard series: SeriesCard
visibleEpisodes: Media[]
allEpisodes: Media[] allEpisodes: Media[]
playbackFrom: string playbackFrom: string
isAdmin: boolean isAdmin: boolean
@@ -31,7 +30,6 @@ type LibrarySeriesDetailHeaderProps = {
export function LibrarySeriesDetailHeader({ export function LibrarySeriesDetailHeader({
series, series,
visibleEpisodes,
allEpisodes, allEpisodes,
playbackFrom, playbackFrom,
isAdmin, isAdmin,
@@ -49,7 +47,9 @@ export function LibrarySeriesDetailHeader({
onOrganize, onOrganize,
onDelete, onDelete,
}: LibrarySeriesDetailHeaderProps) { }: LibrarySeriesDetailHeaderProps) {
const firstEpisode = firstPlayableEpisode(visibleEpisodes.length > 0 ? visibleEpisodes : allEpisodes) const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media))
const theatricalCount = allEpisodes.length - tvEpisodes.length
const firstEpisode = firstPlayableEpisode(tvEpisodes)
return ( return (
<> <>
@@ -61,7 +61,9 @@ export function LibrarySeriesDetailHeader({
<h2 className="truncate font-display text-2xl font-bold text-ink-600"> <h2 className="truncate font-display text-2xl font-bold text-ink-600">
{seriesTitle(series.rep)} {seriesTitle(series.rep)}
</h2> </h2>
<span className="text-sm text-sand-500">共 {series.count} 集</span> <span className="text-sm text-sand-500">
共 {tvEpisodes.length} 集{theatricalCount > 0 ? ` · ${theatricalCount} 部剧场版` : ''}
</span>
</div> </div>
<div className="flex flex-col gap-6 sm:flex-row"> <div className="flex flex-col gap-6 sm:flex-row">
@@ -115,7 +117,7 @@ export function LibrarySeriesDetailHeader({
</button> </button>
<button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5"> <button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Search size={13} className="text-[#c9954a]" /> <Search size={13} className="text-[#c9954a]" />
<span>手动匹配整剧</span> <span>{theatricalCount > 0 ? '手动匹配 TV 版' : '手动匹配整剧'}</span>
</button> </button>
<button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5"> <button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Pencil size={13} /> <Pencil size={13} />
+10 -1
View File
@@ -33,6 +33,7 @@ type LibrarySeriesDetailSectionProps = {
onOrganize: () => void onOrganize: () => void
onDelete: () => void onDelete: () => void
onSeasonChange: (season: number) => void onSeasonChange: (season: number) => void
onManualScrapeMedia: (media: Media) => void
} }
export function LibrarySeriesDetailSection({ export function LibrarySeriesDetailSection({
@@ -58,6 +59,7 @@ export function LibrarySeriesDetailSection({
onOrganize, onOrganize,
onDelete, onDelete,
onSeasonChange, onSeasonChange,
onManualScrapeMedia,
}: LibrarySeriesDetailSectionProps) { }: LibrarySeriesDetailSectionProps) {
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@@ -70,7 +72,6 @@ export function LibrarySeriesDetailSection({
> >
<LibrarySeriesDetailHeader <LibrarySeriesDetailHeader
series={selectedSeries} series={selectedSeries}
visibleEpisodes={visibleEpisodes}
allEpisodes={allEpisodes} allEpisodes={allEpisodes}
playbackFrom={playbackFrom} playbackFrom={playbackFrom}
isAdmin={isAdmin} isAdmin={isAdmin}
@@ -95,7 +96,9 @@ export function LibrarySeriesDetailSection({
selectedSeason={selectedSeason} selectedSeason={selectedSeason}
visibleEpisodes={visibleEpisodes} visibleEpisodes={visibleEpisodes}
playbackFrom={playbackFrom} playbackFrom={playbackFrom}
isAdmin={isAdmin}
onSeasonChange={onSeasonChange} onSeasonChange={onSeasonChange}
onManualScrape={onManualScrapeMedia}
/> />
</motion.div> </motion.div>
)} )}
@@ -109,7 +112,9 @@ type LibrarySeriesEpisodesPanelProps = {
selectedSeason: number | null selectedSeason: number | null
visibleEpisodes: Media[] visibleEpisodes: Media[]
playbackFrom: string playbackFrom: string
isAdmin: boolean
onSeasonChange: (season: number) => void onSeasonChange: (season: number) => void
onManualScrape: (media: Media) => void
} }
function LibrarySeriesEpisodesPanel({ function LibrarySeriesEpisodesPanel({
@@ -118,7 +123,9 @@ function LibrarySeriesEpisodesPanel({
selectedSeason, selectedSeason,
visibleEpisodes, visibleEpisodes,
playbackFrom, playbackFrom,
isAdmin,
onSeasonChange, onSeasonChange,
onManualScrape,
}: LibrarySeriesEpisodesPanelProps) { }: LibrarySeriesEpisodesPanelProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -128,7 +135,9 @@ function LibrarySeriesEpisodesPanel({
selectedSeason={selectedSeason} selectedSeason={selectedSeason}
visibleEpisodes={visibleEpisodes} visibleEpisodes={visibleEpisodes}
playbackFrom={playbackFrom} playbackFrom={playbackFrom}
isAdmin={isAdmin}
onSeasonChange={onSeasonChange} onSeasonChange={onSeasonChange}
onManualScrape={onManualScrape}
/> />
</div> </div>
) )
+28 -5
View File
@@ -1,10 +1,10 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Play } from 'lucide-react' import { Play, Search } from 'lucide-react'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton' import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import type { Media } from '../types' import type { Media } from '../types'
import { seriesTitleFromPath } from '../utils/groupSeries' import { isTheatricalFeature, seriesTitleFromPath, THEATRICAL_SEASON } from '../utils/groupSeries'
import { formatSize } from './libraryPageModel' import { formatSize } from './libraryPageModel'
type SeasonGroup = { type SeasonGroup = {
@@ -18,7 +18,9 @@ type LibrarySeriesEpisodesProps = {
selectedSeason: number | null selectedSeason: number | null
visibleEpisodes: Media[] visibleEpisodes: Media[]
playbackFrom: string playbackFrom: string
isAdmin?: boolean
onSeasonChange: (season: number) => void onSeasonChange: (season: number) => void
onManualScrape?: (media: Media) => void
} }
export function LibrarySeriesEpisodes({ export function LibrarySeriesEpisodes({
@@ -27,7 +29,9 @@ export function LibrarySeriesEpisodes({
selectedSeason, selectedSeason,
visibleEpisodes, visibleEpisodes,
playbackFrom, playbackFrom,
isAdmin = false,
onSeasonChange, onSeasonChange,
onManualScrape,
}: LibrarySeriesEpisodesProps) { }: LibrarySeriesEpisodesProps) {
if (loading) { if (loading) {
return ( return (
@@ -53,14 +57,14 @@ export function LibrarySeriesEpisodes({
: 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600') : 'border-sand-200 bg-white text-ink-100 hover:border-brand-200 hover:text-brand-600')
} }
> >
{season === 0 ? '特别篇' : `第 ${season} 季`} · {episodes.length} 集 {seasonLabel(season)} · {episodes.length} {season === THEATRICAL_SEASON ? '部' : '集'}
</button> </button>
))} ))}
</div> </div>
<div> <div>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600"> <h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`} {seasonLabel(displaySeason)}
</h3> </h3>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"> <div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{visibleEpisodes.map((ep) => { {visibleEpisodes.map((ep) => {
@@ -107,7 +111,20 @@ export function LibrarySeriesEpisodes({
</p> </p>
</div> </div>
</Link> </Link>
<ExternalPlayerButton mediaId={ep.id} label="外部" compact /> <div className="flex shrink-0 items-center gap-1">
{isAdmin && onManualScrape && isTheatricalFeature(ep) && (
<button
type="button"
onClick={() => onManualScrape(ep)}
className="btn-outline !px-2 !py-1.5 text-[11px]"
title="手动匹配剧场版"
>
<Search size={12} />
匹配
</button>
)}
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
</div>
</div> </div>
) )
})} })}
@@ -117,6 +134,12 @@ export function LibrarySeriesEpisodes({
) )
} }
function seasonLabel(season: number): string {
if (season === 0) return '特别篇'
if (season === THEATRICAL_SEASON) return '剧场版'
return `第 ${season} 季`
}
function episodeDisplayTitle(ep: Media, siblings: Media[]): string { function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
let mainTitle = '' let mainTitle = ''
const epTitle = ep.episode_title?.trim() const epTitle = ep.episode_title?.trim()
+12 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import type { Media } from '../types' import type { Media } from '../types'
import { getSeriesKey, type SeriesCard } from '../utils/groupSeries' import { getSeriesKey, isTheatricalFeature, THEATRICAL_SEASON, type SeriesCard } from '../utils/groupSeries'
type SeasonEpisodes = { type SeasonEpisodes = {
season: number season: number
@@ -47,7 +47,9 @@ export function useLibrarySeriesSelection({
: sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key) : sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key)
const seasons = new Map<number, Media[]>() const seasons = new Map<number, Media[]>()
for (const ep of eps) { for (const ep of eps) {
const s = ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1) const s = isTheatricalFeature(ep)
? THEATRICAL_SEASON
: ep.episode_num > 0 ? (ep.season_num ?? 0) : (ep.season_num || 1)
if (!seasons.has(s)) seasons.set(s, []) if (!seasons.has(s)) seasons.set(s, [])
seasons.get(s)!.push(ep) seasons.get(s)!.push(ep)
} }
@@ -55,7 +57,7 @@ export function useLibrarySeriesSelection({
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0)) list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
} }
return Array.from(seasons.entries()) return Array.from(seasons.entries())
.sort(([a], [b]) => a - b) .sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
.map(([season, episodes]) => ({ season, episodes })) .map(([season, episodes]) => ({ season, episodes }))
}, [isSeriesLibrary, selectedSeries, items, seriesEpisodeItems]) }, [isSeriesLibrary, selectedSeries, items, seriesEpisodeItems])
@@ -70,7 +72,7 @@ export function useLibrarySeriesSelection({
) )
const selectedSeriesMediaIDs = useMemo( const selectedSeriesMediaIDs = useMemo(
() => selectedSeriesEpisodes.map((ep) => ep.id), () => selectedSeriesEpisodes.filter((ep) => !isTheatricalFeature(ep)).map((ep) => ep.id),
[selectedSeriesEpisodes], [selectedSeriesEpisodes],
) )
@@ -128,3 +130,9 @@ export function useLibrarySeriesSelection({
clearSelectedSeries, clearSelectedSeries,
} }
} }
function seasonSortOrder(season: number): number {
if (season === 0) return -2
if (season === THEATRICAL_SEASON) return -1
return season
}
+15 -1
View File
@@ -38,6 +38,8 @@ export type SeriesCard = {
last_added_at?: string last_added_at?: string
} }
export const THEATRICAL_SEASON = -1
export function getSeriesKey(media: Media): string { export function getSeriesKey(media: Media): string {
return compactSeriesKey(getSeriesRawKey(media)) return compactSeriesKey(getSeriesRawKey(media))
} }
@@ -92,14 +94,26 @@ export function isEpisodeLike(media: Media): boolean {
const EPISODIC_PATH_RE = const EPISODIC_PATH_RE =
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i /[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
const THEATRICAL_TITLE_RE =
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)/i
const THEATRICAL_FOLDER_RE =
/[\\/](?:剧场版|劇場版|动画电影|動畫電影)[\\/]/
const SEASON_FOLDER_RE = const SEASON_FOLDER_RE =
/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$/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|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇|剧场版|劇場版|动画电影|動畫電影)$/i
export function pathLooksEpisodic(media: Media): boolean { export function pathLooksEpisodic(media: Media): boolean {
const path = (media.path || media.display_library_path || media.library_path || '') const path = (media.path || media.display_library_path || media.library_path || '')
return EPISODIC_PATH_RE.test(path) return EPISODIC_PATH_RE.test(path)
} }
export function isTheatricalFeature(media: Media): boolean {
const path = media.path || ''
if (SERIES_FILE_EPISODE_RE.test(path)) return false
return THEATRICAL_TITLE_RE.test(`${media.title || ''} ${path}`) || THEATRICAL_FOLDER_RE.test(path)
}
export function isSeriesCard(card: SeriesCard): boolean { export function isSeriesCard(card: SeriesCard): boolean {
return ( return (
card.count > 1 || card.count > 1 ||