diff --git a/internal/service/manual_scrape_search.go b/internal/service/manual_scrape_search.go index 9f50f68..3559b25 100644 --- a/internal/service/manual_scrape_search.go +++ b/internal/service/manual_scrape_search.go @@ -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) diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index bc89dcc..0670fb5 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -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) { diff --git a/internal/service/media_series_key.go b/internal/service/media_series_key.go index da0129b..cfa5ce2 100644 --- a/internal/service/media_series_key.go +++ b/internal/service/media_series_key.go @@ -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 { diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index 55ece69..caf16e5 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -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"}, diff --git a/internal/service/scraper_anime_theatrical_test.go b/internal/service/scraper_anime_theatrical_test.go index 8251b7a..b4ce67b 100644 --- a/internal/service/scraper_anime_theatrical_test.go +++ b/internal/service/scraper_anime_theatrical_test.go @@ -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`, diff --git a/internal/service/scraper_query.go b/internal/service/scraper_query.go index 4ed2bab..2e6d6c4 100644 --- a/internal/service/scraper_query.go +++ b/internal/service/scraper_query.go @@ -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 } diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 994615b..2448868 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -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} /> 0 || (media.episode_num ?? 0) > 0) { return 'tv' } diff --git a/web/src/pages/LibrarySeriesDetailHeader.tsx b/web/src/pages/LibrarySeriesDetailHeader.tsx index d6dada2..c17ecd3 100644 --- a/web/src/pages/LibrarySeriesDetailHeader.tsx +++ b/web/src/pages/LibrarySeriesDetailHeader.tsx @@ -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({

{seriesTitle(series.rep)}

- 共 {series.count} 集 + + 共 {tvEpisodes.length} 集{theatricalCount > 0 ? ` · ${theatricalCount} 部剧场版` : ''} +
@@ -115,7 +117,7 @@ export function LibrarySeriesDetailHeader({ ))}

- {displaySeason === 0 ? '特别篇' : `第 ${displaySeason} 季`} + {seasonLabel(displaySeason)}

{visibleEpisodes.map((ep) => { @@ -107,7 +111,20 @@ export function LibrarySeriesEpisodes({

- +
+ {isAdmin && onManualScrape && isTheatricalFeature(ep) && ( + + )} + +
) })} @@ -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() diff --git a/web/src/pages/useLibrarySeriesSelection.ts b/web/src/pages/useLibrarySeriesSelection.ts index d59e705..c2dc7e7 100644 --- a/web/src/pages/useLibrarySeriesSelection.ts +++ b/web/src/pages/useLibrarySeriesSelection.ts @@ -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() 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 +} diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index ec4bf2d..6f8bed7 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -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 ||