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
+96
View File
@@ -340,6 +340,102 @@ func TestEmbyMovieLibraryFiltersMisplacedSeriesPaths(t *testing.T) {
}
}
// TestEmbyMovieLibraryGroupsEpisodicContentIntoSeries 验证方案 B: 电影类型库里
// 混入的「剧集结构」内容(如整合成剧集的剧场版/合集动画, 路径含 Season/SxxE)
// 在常规浏览(未指定 IncludeItemTypes)时应聚成 Series 卡片, 而不是以散装单集
// (Episode)漏出; 同库里真正的电影仍按 Movie 显示。两类并存于同一电影库视图。
func TestEmbyMovieLibraryGroupsEpisodicContentIntoSeries(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "动画电影", Path: `/media/动画电影`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
base := time.Now()
// 剧集结构内容: 同一部「高达剧场版」的两集, 单集 tmdb 各不相同(模拟 NFO 单集 id 污染)。
rows := []model.Media{
{
Base: model.Base{ID: "gundam-e13", CreatedAt: base.Add(2 * time.Minute)},
LibraryID: lib.ID,
Title: "高达剧场版",
Path: `/media/动画电影/高达剧场版/Season 01/高达剧场版 - S01E13.mkv`,
PosterURL: `/poster.jpg`,
TMDbID: 4375419,
SeasonNum: 1,
EpisodeNum: 13,
},
{
Base: model.Base{ID: "gundam-e14", CreatedAt: base.Add(3 * time.Minute)},
LibraryID: lib.ID,
Title: "高达剧场版",
Path: `/media/动画电影/高达剧场版/Season 01/高达剧场版 - S01E14.mkv`,
PosterURL: `/poster.jpg`,
TMDbID: 4375461,
SeasonNum: 1,
EpisodeNum: 14,
},
// 真正的电影。
{
Base: model.Base{ID: "real-movie", CreatedAt: base.Add(1 * time.Minute)},
LibraryID: lib.ID,
Title: "普通动画电影",
Path: `/media/动画电影/普通动画电影.2024.mkv`,
PosterURL: `/poster.jpg`,
},
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50})
if err != nil {
t.Fatalf("items: %v", err)
}
items := out["Items"].([]map[string]any)
var seriesCount, movieCount, episodeCount int
var seriesPayload map[string]any
for _, item := range items {
switch item["Type"] {
case "Series":
seriesCount++
seriesPayload = item
case "Movie":
movieCount++
case "Episode":
episodeCount++
}
}
if episodeCount != 0 {
t.Fatalf("movie library must not leak flat episodes, got %d episode items: %#v", episodeCount, items)
}
if seriesCount != 1 {
t.Fatalf("episodic content should collapse into exactly one Series card, got %d: %#v", seriesCount, items)
}
if movieCount != 1 {
t.Fatalf("real movie should stay as one Movie item, got %d: %#v", movieCount, items)
}
// 两集 tmdb 不同, 但按路径剧名聚成同一 Series, 集数应为 2。
if got := seriesPayload["RecursiveItemCount"]; got != 2 {
t.Fatalf("series should contain both episodes despite differing tmdb ids, got RecursiveItemCount=%v", got)
}
// Series 卡片可下钻: 解析其 season → episodes。
seriesID, _ := seriesPayload["Id"].(string)
if seriesID == "" {
t.Fatalf("series payload missing Id: %#v", seriesPayload)
}
drill, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Recursive: true, Limit: 50})
if err != nil {
t.Fatalf("series drill-down: %v", err)
}
drillItems := drill["Items"].([]map[string]any)
if len(drillItems) != 2 {
t.Fatalf("series drill-down should list both episodes, got %#v", drillItems)
}
}
func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) {
svc := newTestEmbyService(t)
local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true}