mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
优化
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
Reference in New Issue
Block a user