mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 04:26:38 +08:00
fix: guard series metadata from episode identity pollution
This commit is contained in:
@@ -93,6 +93,86 @@ func (s *ScannerService) cloudDirectoryMetadata(ctx context.Context, typ, displa
|
|||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloudFileJSONIsEpisodeMetadata(seriesLike bool, parsedSeason, parsedEpisode int, meta *LocalMetadata) bool {
|
||||||
|
if !seriesLike || meta == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parsedSeason > 0 ||
|
||||||
|
parsedEpisode > 0 ||
|
||||||
|
meta.SeasonNum > 0 ||
|
||||||
|
meta.EpisodeNum > 0 ||
|
||||||
|
strings.TrimSpace(meta.EpisodeTitle) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeCloudEpisodeMetadata(dst, episode *LocalMetadata) *LocalMetadata {
|
||||||
|
if episode == nil {
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
if dst == nil {
|
||||||
|
dst = &LocalMetadata{}
|
||||||
|
}
|
||||||
|
mergeCloudEpisodeIdentity(dst, episode)
|
||||||
|
mergeCloudEpisodeDisplay(dst, episode)
|
||||||
|
mergeCloudEpisodeNumbersAndTaxonomy(dst, episode)
|
||||||
|
dst.NSFW = dst.NSFW || episode.NSFW
|
||||||
|
dst.HasNFO = dst.HasNFO || episode.HasNFO
|
||||||
|
dst.HasArtwork = dst.HasArtwork || episode.HasArtwork
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeCloudEpisodeIdentity(dst, episode *LocalMetadata) {
|
||||||
|
showTitle := ""
|
||||||
|
if episode.EpisodeTitle != "" && episode.Title != "" && !strings.EqualFold(episode.Title, episode.EpisodeTitle) {
|
||||||
|
showTitle = episode.Title
|
||||||
|
}
|
||||||
|
if showTitle != "" {
|
||||||
|
dst.Title = showTitle
|
||||||
|
}
|
||||||
|
episodeTitle := strings.TrimSpace(episode.EpisodeTitle)
|
||||||
|
if episodeTitle == "" && (episode.SeasonNum > 0 || episode.EpisodeNum > 0) {
|
||||||
|
episodeTitle = strings.TrimSpace(episode.Title)
|
||||||
|
}
|
||||||
|
if episodeTitle != "" && !strings.EqualFold(episodeTitle, strings.TrimSpace(dst.Title)) {
|
||||||
|
dst.EpisodeTitle = episodeTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeCloudEpisodeDisplay(dst, episode *LocalMetadata) {
|
||||||
|
if dst.Year == 0 && episode.Year > 0 {
|
||||||
|
dst.Year = episode.Year
|
||||||
|
}
|
||||||
|
if episode.Overview != "" {
|
||||||
|
dst.Overview = episode.Overview
|
||||||
|
}
|
||||||
|
if episode.Rating > 0 {
|
||||||
|
dst.Rating = episode.Rating
|
||||||
|
}
|
||||||
|
if episode.PosterURL != "" {
|
||||||
|
dst.PosterURL = episode.PosterURL
|
||||||
|
}
|
||||||
|
if episode.BackdropURL != "" {
|
||||||
|
dst.BackdropURL = episode.BackdropURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeCloudEpisodeNumbersAndTaxonomy(dst, episode *LocalMetadata) {
|
||||||
|
if episode.SeasonNum > 0 {
|
||||||
|
dst.SeasonNum = episode.SeasonNum
|
||||||
|
}
|
||||||
|
if episode.EpisodeNum > 0 {
|
||||||
|
dst.EpisodeNum = episode.EpisodeNum
|
||||||
|
}
|
||||||
|
if dst.Genres == "" && episode.Genres != "" {
|
||||||
|
dst.Genres = episode.Genres
|
||||||
|
}
|
||||||
|
if dst.Countries == "" && episode.Countries != "" {
|
||||||
|
dst.Countries = episode.Countries
|
||||||
|
}
|
||||||
|
if dst.Languages == "" && episode.Languages != "" {
|
||||||
|
dst.Languages = episode.Languages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath, fileName string, sidecars cloudSidecarSet, inherited *LocalMetadata, seriesLike bool) *LocalMetadata {
|
func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath, fileName string, sidecars cloudSidecarSet, inherited *LocalMetadata, seriesLike bool) *LocalMetadata {
|
||||||
season, episode := ParseEpisode(displayPath)
|
season, episode := ParseEpisode(displayPath)
|
||||||
seriesLike = seriesLike || season > 0 || episode > 0
|
seriesLike = seriesLike || season > 0 || episode > 0
|
||||||
@@ -121,7 +201,11 @@ func (s *ScannerService) cloudFileMetadata(ctx context.Context, typ, displayPath
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil {
|
if local, err := s.readCloudJSONMetadata(ctx, typ, ref, sidecars); err == nil && local != nil {
|
||||||
meta = mergeCloudMetadata(meta, local)
|
if cloudFileJSONIsEpisodeMetadata(seriesLike, season, episode, local) {
|
||||||
|
meta = mergeCloudEpisodeMetadata(meta, local)
|
||||||
|
} else {
|
||||||
|
meta = mergeCloudMetadata(meta, local)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// pollutedEpisodeCleanupSettingKey marks that the one-time normalization has run.
|
// pollutedEpisodeCleanupSettingKey marks that the one-time normalization has run.
|
||||||
const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_done"
|
const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_v2_done"
|
||||||
|
|
||||||
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
|
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
|
||||||
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
|
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -71,11 +72,17 @@ func (h mediaExternalIDHints) applyToLocalMetadata(meta *LocalMetadata) *LocalMe
|
|||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
func pathHintMetadata(raw string, _ bool) (*LocalMetadata, mediaExternalIDHints) {
|
func pathHintMetadata(raw string, seriesLike bool) (*LocalMetadata, mediaExternalIDHints) {
|
||||||
hints := externalIDHintsFromText(raw)
|
source := pathHintSourceText(raw, seriesLike)
|
||||||
title, year := cloudSeriesTitleFromMediaPath(raw)
|
hints := externalIDHintsFromText(source)
|
||||||
if title == "" {
|
title, year := "", 0
|
||||||
title, year = CleanQuery(raw)
|
if seriesLike {
|
||||||
|
title, year = CleanQuery(source)
|
||||||
|
} else {
|
||||||
|
title, year = cloudSeriesTitleFromMediaPath(source)
|
||||||
|
if title == "" {
|
||||||
|
title, year = CleanQuery(source)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !hints.useful() && title == "" && year <= 0 {
|
if !hints.useful() && title == "" && year <= 0 {
|
||||||
return nil, hints
|
return nil, hints
|
||||||
@@ -94,3 +101,19 @@ func pathHintMetadata(raw string, _ bool) (*LocalMetadata, mediaExternalIDHints)
|
|||||||
}
|
}
|
||||||
return meta, hints
|
return meta, hints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pathHintSourceText(raw string, seriesLike bool) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if !seriesLike || raw == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
base := pathBaseSlash(raw)
|
||||||
|
ext := strings.ToLower(filepath.Ext(base))
|
||||||
|
if _, ok := videoExtensions[ext]; !ok {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
if showDir := showDirFromEpisodePath(raw); showDir != "" {
|
||||||
|
return showDir
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 单集级展示字段: 每个媒体行本就对应一集,这些可安全按集回填。
|
// 单集级展示字段: 每个媒体行本就对应一集,这些可安全按集回填。
|
||||||
if episode.Year > 0 {
|
if dst.Year == 0 && episode.Year > 0 {
|
||||||
dst.Year = episode.Year
|
dst.Year = episode.Year
|
||||||
}
|
}
|
||||||
if episode.Overview != "" {
|
if episode.Overview != "" {
|
||||||
@@ -446,7 +446,7 @@ func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
|
|||||||
dst.BackdropURL = episode.BackdropURL
|
dst.BackdropURL = episode.BackdropURL
|
||||||
}
|
}
|
||||||
// 整剧外部 id: 单集 NFO 的 id 都是单集级,绝不写入整剧字段(见上方说明)。
|
// 整剧外部 id: 单集 NFO 的 id 都是单集级,绝不写入整剧字段(见上方说明)。
|
||||||
if episode.SeasonNum > 0 || episode.EpisodeNum > 0 {
|
if episode.SeasonNum > 0 {
|
||||||
dst.SeasonNum = episode.SeasonNum
|
dst.SeasonNum = episode.SeasonNum
|
||||||
}
|
}
|
||||||
if episode.EpisodeNum > 0 {
|
if episode.EpisodeNum > 0 {
|
||||||
|
|||||||
@@ -112,6 +112,18 @@ func TestReadLocalEpisodeMetadataWithoutShowTitleDoesNotUseEpisodeTitleAsSeries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeEpisodeMetadataKeepsExistingSeasonWhenEpisodeNFOOmitsIt(t *testing.T) {
|
||||||
|
dst := &LocalMetadata{Title: "哈哈哈哈哈", SeasonNum: 6}
|
||||||
|
episodeDoc := &nfoDocument{Title: "第 11 集", Episode: 11}
|
||||||
|
episode := metadataFromDoc(episodeDoc, "", true)
|
||||||
|
|
||||||
|
mergeEpisodeMetadata(dst, episode, episodeDoc)
|
||||||
|
|
||||||
|
if dst.SeasonNum != 6 || dst.EpisodeNum != 11 {
|
||||||
|
t.Fatalf("episode NFO without season should keep parsed season, got %+v", dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadLocalEpisodeMetadataIgnoresNoneNumericFields(t *testing.T) {
|
func TestReadLocalEpisodeMetadataIgnoresNoneNumericFields(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
showDir := filepath.Join(root, "链锯人 总集篇 (2025)")
|
showDir := filepath.Join(root, "链锯人 总集篇 (2025)")
|
||||||
|
|||||||
@@ -263,6 +263,32 @@ func TestScanCloudLibraryReadsRemoteJSONMetadataAndArtwork(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCloudEpisodeJSONDoesNotPolluteSeriesIdentity(t *testing.T) {
|
||||||
|
local, _ := metadataFromCloudJSON([]byte(`{
|
||||||
|
"title": "九龙拉棺",
|
||||||
|
"episode": 1,
|
||||||
|
"tmdb_id": 4375419,
|
||||||
|
"original_title": "Burial",
|
||||||
|
"overview": "本集简介",
|
||||||
|
"poster": "episode-poster.jpg",
|
||||||
|
"genres": ["动画"]
|
||||||
|
}`))
|
||||||
|
if local == nil {
|
||||||
|
t.Fatal("episode json metadata was not parsed")
|
||||||
|
}
|
||||||
|
dst := &LocalMetadata{Title: "遮天", Year: 2023, SeasonNum: 1}
|
||||||
|
got := mergeCloudEpisodeMetadata(dst, local)
|
||||||
|
if got.Title != "遮天" || got.OriginalName != "" || got.TMDbID != 0 {
|
||||||
|
t.Fatalf("episode json polluted series identity: %+v", got)
|
||||||
|
}
|
||||||
|
if got.EpisodeTitle != "九龙拉棺" || got.Overview != "本集简介" || got.SeasonNum != 1 || got.EpisodeNum != 1 {
|
||||||
|
t.Fatalf("episode json fields not preserved: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Genres != "动画" {
|
||||||
|
t.Fatalf("episode json taxonomy should fill empty series taxonomy, got %q", got.Genres)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScanCloudLibraryEnrichesPathHintTMDbArtwork(t *testing.T) {
|
func TestScanCloudLibraryEnrichesPathHintTMDbArtwork(t *testing.T) {
|
||||||
tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
tmdb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/movie/755679" {
|
if r.URL.Path != "/movie/755679" {
|
||||||
|
|||||||
@@ -212,26 +212,34 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
|||||||
mediaType = "tv"
|
mediaType = "tv"
|
||||||
}
|
}
|
||||||
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
|
if match := s.manualTMDbMatchByID(ctx, m.TMDbID, normalizeMediaType(mediaType, m.Title, "")); match != nil {
|
||||||
return match
|
if s.mediaExternalIDMatchTrusted(m, lib, match, "tmdb") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(m.DoubanID) != "" && s.douban != nil && s.douban.Enabled() {
|
if strings.TrimSpace(m.DoubanID) != "" && s.douban != nil && s.douban.Enabled() {
|
||||||
if match, err := s.douban.GetMatchByID(ctx, strings.TrimSpace(m.DoubanID)); err == nil && match != nil {
|
if match, err := s.douban.GetMatchByID(ctx, strings.TrimSpace(m.DoubanID)); err == nil && match != nil {
|
||||||
return match
|
if s.mediaExternalIDMatchTrusted(m, lib, match, "douban") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.log.Debug("douban id lookup failed", zap.String("media_id", m.ID), zap.String("douban_id", m.DoubanID), zap.Error(err))
|
s.log.Debug("douban id lookup failed", zap.String("media_id", m.ID), zap.String("douban_id", m.DoubanID), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.BangumiID > 0 && s.bangumi != nil && s.bangumi.Enabled() {
|
if m.BangumiID > 0 && s.bangumi != nil && s.bangumi.Enabled() {
|
||||||
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
|
if match, err := s.bangumi.GetSubject(ctx, m.BangumiID); err == nil && match != nil {
|
||||||
return match
|
if s.mediaExternalIDMatchTrusted(m, lib, match, "bangumi") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.log.Debug("bangumi id lookup failed", zap.String("media_id", m.ID), zap.Int("bangumi_id", m.BangumiID), zap.Error(err))
|
s.log.Debug("bangumi id lookup failed", zap.String("media_id", m.ID), zap.Int("bangumi_id", m.BangumiID), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(m.TheTVDBID) != "" && s.thetvdb != nil && s.thetvdb.Enabled() {
|
if strings.TrimSpace(m.TheTVDBID) != "" && s.thetvdb != nil && s.thetvdb.Enabled() {
|
||||||
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, strings.TrimSpace(m.TheTVDBID)); err == nil && match != nil {
|
if match, err := s.thetvdb.GetSeriesMatchByID(ctx, strings.TrimSpace(m.TheTVDBID)); err == nil && match != nil {
|
||||||
return match
|
if s.mediaExternalIDMatchTrusted(m, lib, match, "thetvdb") {
|
||||||
|
return match
|
||||||
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.log.Debug("thetvdb id lookup failed", zap.String("media_id", m.ID), zap.String("thetvdb_id", m.TheTVDBID), zap.Error(err))
|
s.log.Debug("thetvdb id lookup failed", zap.String("media_id", m.ID), zap.String("thetvdb_id", m.TheTVDBID), zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -239,6 +247,35 @@ func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ScraperService) mediaExternalIDMatchTrusted(m *model.Media, lib *model.Library, match *Match, source string) bool {
|
||||||
|
if match == nil || strings.TrimSpace(match.Title) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !mediaIsEpisodic(m, lib) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, candidate := range scrapeQueryCandidates(m, lib) {
|
||||||
|
if unsafeAutomaticEpisodeQuery(candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if automaticMetadataTitleTrusted(candidate, match) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s != nil && s.log != nil && m != nil {
|
||||||
|
s.log.Warn("episode external id match rejected",
|
||||||
|
zap.String("media_id", m.ID),
|
||||||
|
zap.String("source", source),
|
||||||
|
zap.String("title", match.Title),
|
||||||
|
zap.String("original_name", match.OriginalName),
|
||||||
|
zap.Int("tmdb_id", match.TMDbID),
|
||||||
|
zap.Int("bangumi_id", match.BangumiID),
|
||||||
|
zap.String("douban_id", match.DoubanID),
|
||||||
|
zap.String("thetvdb_id", match.TheTVDBID))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ScraperService) applyFanartArtwork(ctx context.Context, match *Match) {
|
func (s *ScraperService) applyFanartArtwork(ctx context.Context, match *Match) {
|
||||||
if s == nil || s.fanart == nil || !s.fanart.Enabled() || match == nil {
|
if s == nil || s.fanart == nil || !s.fanart.Enabled() || match == nil {
|
||||||
return
|
return
|
||||||
@@ -274,6 +311,12 @@ func mediaYearHint(m *model.Media) int {
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||||
|
return seriesPathYearHint(m.Path)
|
||||||
|
}
|
||||||
|
if season, episode := ParseEpisode(m.Path); season > 0 || episode > 0 {
|
||||||
|
return seriesPathYearHint(m.Path)
|
||||||
|
}
|
||||||
if m.Year > 0 {
|
if m.Year > 0 {
|
||||||
return m.Year
|
return m.Year
|
||||||
}
|
}
|
||||||
@@ -283,6 +326,15 @@ func mediaYearHint(m *model.Media) int {
|
|||||||
return yearFromText(m.Path)
|
return yearFromText(m.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seriesPathYearHint(path string) int {
|
||||||
|
if showDir := showDirFromEpisodePath(path); showDir != "" {
|
||||||
|
if _, year := CleanQuery(showDir); year > 0 {
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func yearFromText(raw string) int {
|
func yearFromText(raw string) int {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ func TestPathHintMetadataDoesNotMarkMediaMatched(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPathHintMetadataIgnoresEpisodeFileIDForSeries(t *testing.T) {
|
||||||
|
meta, hints := pathHintMetadata("cloud://openlist/国漫/遮天 (2023)/Season 1/遮天.S01E01.{tmdb-4375419}.mkv", true)
|
||||||
|
if meta == nil || meta.Title != "遮天" || meta.Year != 2023 {
|
||||||
|
t.Fatalf("series path hint metadata = %+v", meta)
|
||||||
|
}
|
||||||
|
if hints.TMDbID != 0 || meta.TMDbID != 0 {
|
||||||
|
t.Fatalf("episode filename tmdb id must not become series id: meta=%+v hints=%+v", meta, hints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnrichOneCloudPathHintOverridesStaleTMDbID(t *testing.T) {
|
func TestEnrichOneCloudPathHintOverridesStaleTMDbID(t *testing.T) {
|
||||||
var requested []string
|
var requested []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) {
|
||||||
@@ -168,6 +178,124 @@ func TestEnrichOneCloudPathHintOverridesStaleTMDbID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnrichOneRejectsStaleEpisodeTMDbIDBySeriesTitle(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requested = append(requested, r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/tv/220269":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": 220269,
|
||||||
|
"name": "错误剧集",
|
||||||
|
"overview": "stale episode id resolved as another show",
|
||||||
|
"first_air_date": "2024-01-01",
|
||||||
|
})
|
||||||
|
case "/search/tv":
|
||||||
|
if r.URL.Query().Get("query") != "折腰" {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 296753,
|
||||||
|
"name": "折腰",
|
||||||
|
"overview": "正确的剧集条目",
|
||||||
|
"poster_path": "/zheyao.jpg",
|
||||||
|
"first_air_date": "2025-05-13",
|
||||||
|
"origin_country": []string{"CN"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case "/tv/296753":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": 296753,
|
||||||
|
"name": "折腰",
|
||||||
|
"overview": "正确的剧集条目",
|
||||||
|
"poster_path": "/zheyao.jpg",
|
||||||
|
"first_air_date": "2025-05-13",
|
||||||
|
"origin_country": []string{"CN"},
|
||||||
|
})
|
||||||
|
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
|
||||||
|
cfg.Secrets.TMDbImageProxy = upstream.URL + "/images"
|
||||||
|
log := zap.NewNop()
|
||||||
|
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
||||||
|
|
||||||
|
lib := model.Library{Name: "OpenList · 国产剧", Path: "cloud://openlist/国产剧", Type: "tv", Enabled: true}
|
||||||
|
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
media := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "折腰",
|
||||||
|
Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
TMDbID: 220269,
|
||||||
|
ScrapeStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := repos.DB.Create(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got model.Media
|
||||||
|
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ScrapeStatus != "matched" || got.TMDbID != 296753 || got.Title != "折腰" || got.PosterURL == "" {
|
||||||
|
t.Fatalf("stale tmdb id should be rejected and repaired by title search: status=%q tmdb=%d title=%q poster=%q requests=%v",
|
||||||
|
got.ScrapeStatus, got.TMDbID, got.Title, got.PosterURL, requested)
|
||||||
|
}
|
||||||
|
if firstIndexFunc(requested, func(path string) bool { return path == "/tv/220269" }) < 0 {
|
||||||
|
t.Fatalf("test did not exercise stale id lookup: requests=%v", requested)
|
||||||
|
}
|
||||||
|
if firstIndexFunc(requested, func(path string) bool { return path == "/search/tv" }) < 0 {
|
||||||
|
t.Fatalf("scraper did not fall back to title search after stale id rejection: requests=%v", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaYearHintUsesSeriesFolderYearForEpisodes(t *testing.T) {
|
||||||
|
media := &model.Media{
|
||||||
|
Year: 2026,
|
||||||
|
Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
}
|
||||||
|
if got := mediaYearHint(media); got != 2025 {
|
||||||
|
t.Fatalf("mediaYearHint = %d, want series folder year 2025", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaYearHintIgnoresEpisodeRowYearWithoutSeriesFolderYear(t *testing.T) {
|
||||||
|
media := &model.Media{
|
||||||
|
Year: 2026,
|
||||||
|
Path: "cloud://openlist/综艺/哈哈哈哈哈/Season 6/哈哈哈哈哈 - S06E01.mkv",
|
||||||
|
SeasonNum: 6,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
}
|
||||||
|
if got := mediaYearHint(media); got != 0 {
|
||||||
|
t.Fatalf("mediaYearHint = %d, want no year when only episode row year is available", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnrichOneUsesLocalPathExternalIDHints(t *testing.T) {
|
func TestEnrichOneUsesLocalPathExternalIDHints(t *testing.T) {
|
||||||
var requested []string
|
var requested []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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user