fix(series): 彻底修复剧集被按单集显示且无法修复

根因(用真实数据确诊, 两个互相独立):
- Web 端拆集: episode NFO 的 <uniqueid type=tmdb> 是【单集 episode id】、
  <title> 是【单集名】, mergeEpisodeMetadata 却无条件覆盖了整剧 tm_db_id /
  original_name → 同剧每集 id/原名各不相同, 被 getSeriesKey 按 tmdb_id 拆成
  N 张单集卡(实测「遮天」90 集 = 89 个不同 tmdb_id)。
- Emby 散装单集: 电影库混入按 Season/SxxE 整理的剧集结构内容, 被判成 Episode
  却因 movie 库不聚 Series 而漏成散装单集。
- 无法修复: EnrichLibrary 只处理 pending/no_match, 跳过 matched 脏行。

修复:
- local_metadata.mergeEpisodeMetadata: 单集 NFO 的外部 id 不再写入整剧字段,
  单集名不再污染 original_name; 整剧 id/原名只认 tvshow.nfo。
- groupSeries.ts: 剧集分组改「库+路径剧名」优先(整剧目录对全剧一致), 外部 id
  仅作回退, 对存量脏数据也能合并。
- emby_compat: 电影库常规浏览新增 movieLibraryItems, 把剧集结构内容聚成 Series
  卡片与真电影并列(方案 B), 不再漏散装单集。
- cloud_path_repair: 「修复+重刮」先把脏的 matched 剧集行重置为 pending 再重刮,
  使其能被重新刮削; 结果新增 reset 计数。
- 新增 NormalizePollutedEpisodeMetadata 启动迁移(幂等): 按整剧目录聚合, 清洗
  tmdb/原名散乱的剧组并重置 pending。

测试: local_metadata/emby_compat/cloud_path_repair/episode_metadata_cleanup
新增或更新断言; 修正 scanner_cloud_test 中依赖旧错误行为的用例。
This commit is contained in:
ShukeBta
2026-06-20 01:04:19 +08:00
parent b40e2c7280
commit dff19801b6
12 changed files with 658 additions and 37 deletions
+26 -25
View File
@@ -404,22 +404,33 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
}
}
// mergeEpisodeMetadata 把单集 sidecar NFO(<episodedetails>)合并进整剧元数据
// dst(通常来自 tvshow.nfo)。
//
// 关键约束:「整剧级」字段(Title/OriginalName/TMDbID/BangumiID/DoubanID/TheTVDBID)
// 是合集分组键的依据,必须保证「同一部剧的各集一致」。而单集 NFO 里的
// <uniqueid type="tmdb"> 是【单集 episode id】(如 4375419)、<title> 是【单集名】
// (如「九龙拉棺」),都是单集级数据 —— 一旦写进整剧字段,同剧每集的 id/原名互不
// 相同,会被前端 getSeriesKey / Emby seriesGroupsFromMedia 拆成多张卡(每集一卡)。
// 因此:
// - 单集外部 id(tmdb/bangumi/douban/thetvdb)一律【不写入】整剧外部 id;
// 整剧 id 只认 tvshow.nfo(已在 dst);无则留空,交由路径剧名分组兜底。
// - 单集名【不写入】OriginalName(整剧原名);整剧原名只来自 tvshow.nfo。
// - 仅 overview/rating/剧照/季集号等【单集级】字段按集回填(不影响分组)。
func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
showTitle := cleanXMLText(doc.ShowTitle)
episodeTitle := cleanXMLText(doc.Title)
// 整剧标题: 优先 <showtitle>(MoviePilot 在单集 NFO 里也会写整剧名);
// 其次保留 dst 已有(来自 tvshow.nfo);最后才退而用单集名占位。
if showTitle != "" {
dst.Title = showTitle
if episodeTitle != "" {
dst.OriginalName = episodeTitle
} else if dst.Title == "" {
if episodeTitle := cleanXMLText(doc.Title); episodeTitle != "" {
dst.Title = episodeTitle
}
} else if dst.Title != "" && episodeTitle != "" && episodeTitle != dst.Title {
dst.OriginalName = episodeTitle
} else if dst.Title == "" && episodeTitle != "" {
dst.Title = episodeTitle
}
if dst.OriginalName == "" && episode.OriginalName != "" {
dst.OriginalName = episode.OriginalName
}
// 注意: 不要把单集名 / 单集 originaltitle 写进 OriginalName(整剧原名,分组键)。
// 单集级展示字段: 每个媒体行本就对应一集,这些可安全按集回填。
if episode.Year > 0 {
dst.Year = episode.Year
}
@@ -435,31 +446,21 @@ func mergeEpisodeMetadata(dst, episode *LocalMetadata, doc *nfoDocument) {
if episode.BackdropURL != "" {
dst.BackdropURL = episode.BackdropURL
}
if episode.TMDbID > 0 {
dst.TMDbID = episode.TMDbID
}
if episode.BangumiID > 0 {
dst.BangumiID = episode.BangumiID
}
if episode.DoubanID != "" {
dst.DoubanID = episode.DoubanID
}
if episode.TheTVDBID != "" {
dst.TheTVDBID = episode.TheTVDBID
}
// 整剧外部 id: 单集 NFO 的 id 都是单集级,绝不写入整剧字段(见上方说明)。
if episode.SeasonNum > 0 || episode.EpisodeNum > 0 {
dst.SeasonNum = episode.SeasonNum
}
if episode.EpisodeNum > 0 {
dst.EpisodeNum = episode.EpisodeNum
}
if episode.Genres != "" {
// 题材/地区/语言为整剧级,单集 NFO 偶尔携带时仅在整剧未提供时回填。
if dst.Genres == "" && episode.Genres != "" {
dst.Genres = episode.Genres
}
if episode.Countries != "" {
if dst.Countries == "" && episode.Countries != "" {
dst.Countries = episode.Countries
}
if episode.Languages != "" {
if dst.Languages == "" && episode.Languages != "" {
dst.Languages = episode.Languages
}
}