mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62206aa05a |
@@ -24,6 +24,13 @@ func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, q
|
||||
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], "")
|
||||
providers := manualSearchProviderSet(provider)
|
||||
year := mediaYearHint(media)
|
||||
|
||||
@@ -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) {
|
||||
var paths []string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -173,7 +173,7 @@ func seriesTitleFromMediaPath(path string) string {
|
||||
if last := parts[len(parts)-1]; !seriesPathPartLooksLikeFile(last) && !seriesSeasonDirRE.MatchString(filepath.Base(last)) {
|
||||
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--
|
||||
}
|
||||
if dirIndex < 0 {
|
||||
|
||||
@@ -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) {
|
||||
movie := model.Media{
|
||||
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) {
|
||||
lib := &model.Library{
|
||||
Path: `/media/anime`,
|
||||
|
||||
@@ -40,9 +40,10 @@ func mediaLooksLikeTheatricalFeature(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
return false
|
||||
}
|
||||
// Trust an episode marker that is actually present in the path, but do not
|
||||
// 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 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user