Files
MeBox/internal/service/cloud_path_repair_test.go
T
ShukeBta dff19801b6 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 中依赖旧错误行为的用例。
2026-06-20 01:04:19 +08:00

68 lines
2.6 KiB
Go

package service
import (
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// TestResetEpisodicMatchedForRescrape 验证「修复+重刮」会把脏的 matched 剧集行
// 重置为 pending(让 EnrichLibrary 能重新刮削),而电影行与其它库不受影响。
func TestResetEpisodicMatchedForRescrape(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
container := &Container{Repo: repository.New(db), Log: zap.NewNop()}
rows := []model.Media{
// 目标库的剧集行(matched, 有季集号)→ 应被重置。
{Base: model.Base{ID: "ep1"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 1, ScrapeStatus: "matched", Path: "/a/show/S01/ep1.mkv"},
{Base: model.Base{ID: "ep2"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 2, ScrapeStatus: "matched", Path: "/a/show/S01/ep2.mkv"},
// 目标库的电影行(无季集号)→ 不应被重置。
{Base: model.Base{ID: "movie1"}, LibraryID: "lib-a", ScrapeStatus: "matched", Path: "/a/movie.mkv"},
// 目标库已是 pending 的剧集行 → 不计入重置数。
{Base: model.Base{ID: "ep3"}, LibraryID: "lib-a", SeasonNum: 1, EpisodeNum: 3, ScrapeStatus: "pending", Path: "/a/show/S01/ep3.mkv"},
// 其它库的剧集行(matched)→ 单库重置时不应受影响。
{Base: model.Base{ID: "ep-other"}, LibraryID: "lib-b", SeasonNum: 1, EpisodeNum: 1, ScrapeStatus: "matched", Path: "/b/show/S01/ep1.mkv"},
}
for i := range rows {
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media %s: %v", rows[i].ID, err)
}
}
reset, err := container.resetEpisodicMatchedForRescrape(t.Context(), "lib-a")
if err != nil {
t.Fatalf("reset: %v", err)
}
if reset != 2 {
t.Fatalf("reset = %d, want 2 (only matched episodic rows in lib-a)", reset)
}
status := func(id string) string {
var m model.Media
if err := db.First(&m, "id = ?", id).Error; err != nil {
t.Fatalf("load %s: %v", id, err)
}
return m.ScrapeStatus
}
if status("ep1") != "pending" || status("ep2") != "pending" {
t.Fatalf("episodic matched rows should be pending, got ep1=%q ep2=%q", status("ep1"), status("ep2"))
}
if status("movie1") != "matched" {
t.Fatalf("movie row should stay matched, got %q", status("movie1"))
}
if status("ep-other") != "matched" {
t.Fatalf("other library row should stay matched, got %q", status("ep-other"))
}
}