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
+6 -1
View File
@@ -5,7 +5,7 @@ import { motion } from 'framer-motion'
import { historyAPI } from '../api/history'
import type { Media } from '../types'
import { useAuthStore } from '../stores/auth'
import type { SeriesCard } from '../utils/groupSeries'
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
import {
sortMediaList,
sortSeriesList,
@@ -150,6 +150,9 @@ export function LibraryPage() {
setSelectedSeason,
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
})
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
?? selectedSeries?.rep
?? null
const {
scraping,
@@ -287,6 +290,7 @@ export function LibraryPage() {
onOrganize={handleSeriesOrganize}
onDelete={handleSeriesDelete}
onSeasonChange={setSelectedSeason}
onManualScrapeMedia={setManualMovie}
/>
<LibraryPageDialogs
@@ -296,6 +300,7 @@ export function LibraryPage() {
seriesMetadataEditOpen={seriesMetadataEditOpen}
manualMovie={manualMovie}
selectedSeries={selectedSeries}
selectedSeriesScrapeMedia={selectedSeriesScrapeMedia}
selectedSeriesMediaIDs={selectedSeriesMediaIDs}
libraryType={library?.type}
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
+8 -3
View File
@@ -2,7 +2,7 @@ import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { ScrapeMetadataDialog } from '../components/ScrapeMetadataDialog'
import type { Library, Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
type LibraryPageDialogsProps = {
scrapeDialogOpen: boolean
@@ -11,6 +11,7 @@ type LibraryPageDialogsProps = {
seriesMetadataEditOpen: boolean
manualMovie: Media | null
selectedSeries: SeriesCard | null
selectedSeriesScrapeMedia: Media | null
selectedSeriesMediaIDs: string[]
libraryType?: string
scrapeEpisodeArtwork: boolean
@@ -29,6 +30,7 @@ export function LibraryPageDialogs({
seriesMetadataEditOpen,
manualMovie,
selectedSeries,
selectedSeriesScrapeMedia,
selectedSeriesMediaIDs,
libraryType,
scrapeEpisodeArtwork,
@@ -53,10 +55,10 @@ export function LibraryPageDialogs({
/>
<ManualScrapeDialog
open={manualSeriesScrapeOpen}
media={selectedSeries?.rep ?? null}
media={selectedSeriesScrapeMedia}
mediaIds={selectedSeriesMediaIDs}
defaultQuery={selectedSeriesTitle}
mediaType={selectedSeries ? scrapeMediaType(libraryType, selectedSeries.rep) : 'tv'}
mediaType="tv"
scopeLabel={selectedSeriesTitle || '当前剧集'}
episodeArtwork={scrapeEpisodeArtwork}
onClose={onCloseManualSeriesScrape}
@@ -86,6 +88,9 @@ export function LibraryPageDialogs({
}
function scrapeMediaType(libraryType: string | undefined, media: Media): string {
if (isTheatricalFeature(media)) {
return 'movie'
}
if ((media.season_num ?? 0) > 0 || (media.episode_num ?? 0) > 0) {
return 'tv'
}
+8 -6
View File
@@ -6,11 +6,10 @@ import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import type { Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
type LibrarySeriesDetailHeaderProps = {
series: SeriesCard
visibleEpisodes: Media[]
allEpisodes: Media[]
playbackFrom: string
isAdmin: boolean
@@ -31,7 +30,6 @@ type LibrarySeriesDetailHeaderProps = {
export function LibrarySeriesDetailHeader({
series,
visibleEpisodes,
allEpisodes,
playbackFrom,
isAdmin,
@@ -49,7 +47,9 @@ export function LibrarySeriesDetailHeader({
onOrganize,
onDelete,
}: 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 (
<>
@@ -61,7 +61,9 @@ export function LibrarySeriesDetailHeader({
<h2 className="truncate font-display text-2xl font-bold text-ink-600">
{seriesTitle(series.rep)}
</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 className="flex flex-col gap-6 sm:flex-row">
@@ -115,7 +117,7 @@ export function LibrarySeriesDetailHeader({
</button>
<button onClick={onManualScrape} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Search size={13} className="text-[#c9954a]" />
<span>手动匹配整剧</span>
<span>{theatricalCount > 0 ? '手动匹配 TV 版' : '手动匹配整剧'}</span>
</button>
<button onClick={onMetadataEdit} disabled={!!seriesToolBusy} className="btn-outline px-3.5 py-2 text-xs gap-1.5">
<Pencil size={13} />
+10 -1
View File
@@ -33,6 +33,7 @@ type LibrarySeriesDetailSectionProps = {
onOrganize: () => void
onDelete: () => void
onSeasonChange: (season: number) => void
onManualScrapeMedia: (media: Media) => void
}
export function LibrarySeriesDetailSection({
@@ -58,6 +59,7 @@ export function LibrarySeriesDetailSection({
onOrganize,
onDelete,
onSeasonChange,
onManualScrapeMedia,
}: LibrarySeriesDetailSectionProps) {
return (
<AnimatePresence mode="wait">
@@ -70,7 +72,6 @@ export function LibrarySeriesDetailSection({
>
<LibrarySeriesDetailHeader
series={selectedSeries}
visibleEpisodes={visibleEpisodes}
allEpisodes={allEpisodes}
playbackFrom={playbackFrom}
isAdmin={isAdmin}
@@ -95,7 +96,9 @@ export function LibrarySeriesDetailSection({
selectedSeason={selectedSeason}
visibleEpisodes={visibleEpisodes}
playbackFrom={playbackFrom}
isAdmin={isAdmin}
onSeasonChange={onSeasonChange}
onManualScrape={onManualScrapeMedia}
/>
</motion.div>
)}
@@ -109,7 +112,9 @@ type LibrarySeriesEpisodesPanelProps = {
selectedSeason: number | null
visibleEpisodes: Media[]
playbackFrom: string
isAdmin: boolean
onSeasonChange: (season: number) => void
onManualScrape: (media: Media) => void
}
function LibrarySeriesEpisodesPanel({
@@ -118,7 +123,9 @@ function LibrarySeriesEpisodesPanel({
selectedSeason,
visibleEpisodes,
playbackFrom,
isAdmin,
onSeasonChange,
onManualScrape,
}: LibrarySeriesEpisodesPanelProps) {
return (
<div className="space-y-6">
@@ -128,7 +135,9 @@ function LibrarySeriesEpisodesPanel({
selectedSeason={selectedSeason}
visibleEpisodes={visibleEpisodes}
playbackFrom={playbackFrom}
isAdmin={isAdmin}
onSeasonChange={onSeasonChange}
onManualScrape={onManualScrape}
/>
</div>
)
+28 -5
View File
@@ -1,10 +1,10 @@
import { Link } from 'react-router-dom'
import { Play } from 'lucide-react'
import { Play, Search } from 'lucide-react'
import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import type { Media } from '../types'
import { seriesTitleFromPath } from '../utils/groupSeries'
import { isTheatricalFeature, seriesTitleFromPath, THEATRICAL_SEASON } from '../utils/groupSeries'
import { formatSize } from './libraryPageModel'
type SeasonGroup = {
@@ -18,7 +18,9 @@ type LibrarySeriesEpisodesProps = {
selectedSeason: number | null
visibleEpisodes: Media[]
playbackFrom: string
isAdmin?: boolean
onSeasonChange: (season: number) => void
onManualScrape?: (media: Media) => void
}
export function LibrarySeriesEpisodes({
@@ -27,7 +29,9 @@ export function LibrarySeriesEpisodes({
selectedSeason,
visibleEpisodes,
playbackFrom,
isAdmin = false,
onSeasonChange,
onManualScrape,
}: LibrarySeriesEpisodesProps) {
if (loading) {
return (
@@ -53,14 +57,14 @@ export function LibrarySeriesEpisodes({
: '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>
))}
</div>
<div>
<h3 className="mb-3 font-display text-lg font-semibold text-ink-600">
{displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`}
{seasonLabel(displaySeason)}
</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">
{visibleEpisodes.map((ep) => {
@@ -107,7 +111,20 @@ export function LibrarySeriesEpisodes({
</p>
</div>
</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>
)
})}
@@ -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 {
let mainTitle = ''
const epTitle = ep.episode_title?.trim()
+12 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo } from 'react'
import type { Media } from '../types'
import { getSeriesKey, type SeriesCard } from '../utils/groupSeries'
import { getSeriesKey, isTheatricalFeature, THEATRICAL_SEASON, type SeriesCard } from '../utils/groupSeries'
type SeasonEpisodes = {
season: number
@@ -47,7 +47,9 @@ export function useLibrarySeriesSelection({
: sourceItems.filter((m) => getSeriesKey(m) === selectedSeries.key)
const seasons = new Map<number, Media[]>()
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, [])
seasons.get(s)!.push(ep)
}
@@ -55,7 +57,7 @@ export function useLibrarySeriesSelection({
list.sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
}
return Array.from(seasons.entries())
.sort(([a], [b]) => a - b)
.sort(([a], [b]) => seasonSortOrder(a) - seasonSortOrder(b))
.map(([season, episodes]) => ({ season, episodes }))
}, [isSeriesLibrary, selectedSeries, items, seriesEpisodeItems])
@@ -70,7 +72,7 @@ export function useLibrarySeriesSelection({
)
const selectedSeriesMediaIDs = useMemo(
() => selectedSeriesEpisodes.map((ep) => ep.id),
() => selectedSeriesEpisodes.filter((ep) => !isTheatricalFeature(ep)).map((ep) => ep.id),
[selectedSeriesEpisodes],
)
@@ -128,3 +130,9 @@ export function useLibrarySeriesSelection({
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
}
export const THEATRICAL_SEASON = -1
export function getSeriesKey(media: Media): string {
return compactSeriesKey(getSeriesRawKey(media))
}
@@ -92,14 +94,26 @@ export function isEpisodeLike(media: Media): boolean {
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
const THEATRICAL_TITLE_RE =
/(?:剧场版|劇場版|动画电影|動畫電影|电影版|電影版|\bthe\s+movie\b|\bmovie\s*\d{1,2}\b)/i
const THEATRICAL_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 {
const path = (media.path || media.display_library_path || media.library_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 {
return (
card.count > 1 ||