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
+8
View File
@@ -92,6 +92,14 @@ func main() {
logger.Info("cloud path metadata repair completed", zap.Int("media_count", repaired))
}
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
logger.Warn("seed admin failed", zap.Error(err))
}
+2
View File
@@ -24,6 +24,7 @@ func repairAndRescrapeAllHandler(svc *service.Container) gin.HandlerFunc {
"repaired": int64(result.Repaired),
"libraries": int64(result.Libraries),
"matched": int64(result.Matched),
"reset": int64(result.Reset),
}
stage := "completed"
message := "全库修复并重刮完成"
@@ -52,6 +53,7 @@ func repairAndRescrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
"repaired": int64(result.Repaired),
"libraries": int64(result.Libraries),
"matched": int64(result.Matched),
"reset": int64(result.Reset),
}
stage := "completed"
message := "媒体库修复并重刮完成"
+48
View File
@@ -118,6 +118,40 @@ type RepairAndRescrapeResult struct {
Repaired int `json:"repaired"` // 从路径占位符回填外部 ID 的媒体数
Libraries int `json:"libraries"` // 参与重刮的媒体库数
Matched int `json:"matched"` // 重刮后成功匹配的媒体数
Reset int `json:"reset"` // 被重置为 pending 以便重刮的剧集行数
}
// resetEpisodicMatchedForRescrape 把剧集类(有季集号)且已 matched 的行重置为
// pending,使 EnrichLibrary(只处理 pending/no_match)能重新刮削它们。
//
// 背景: 历史版本(commit b44c7f8)曾把【单集 episode id】写进整剧 tm_db_id、把
// 单集名写进 original_name,污染了合集分组键 —— 同一部剧每集 id/原名各不相同,
// 被前端 / Emby 拆成 N 张单集卡。这些行 scrape_status 多为 matched,常规「修复+
// 重刮」会跳过,导致「无法修复」。源头已在 local_metadata.go 修正,这里把脏的
// matched 剧集行放回 pending,借重刮写回正确的整剧 ID / 原名。
//
// libraryID 为空时处理全库;非空时仅该库。返回被重置的行数。
func (c *Container) resetEpisodicMatchedForRescrape(ctx context.Context, libraryID string) (int, error) {
if c == nil || c.Repo == nil || c.Repo.DB == nil {
return 0, nil
}
q := c.Repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("(season_num > 0 OR episode_num > 0)").
Where("LOWER(scrape_status) = ?", "matched")
if id := strings.TrimSpace(libraryID); id != "" {
q = q.Where("library_id = ?", id)
}
res := q.Update("scrape_status", "pending")
if res.Error != nil {
return 0, res.Error
}
reset := int(res.RowsAffected)
if reset > 0 && c.Log != nil {
c.Log.Info("episodic matched rows reset to pending for rescrape",
zap.String("library", strings.TrimSpace(libraryID)),
zap.Int("reset", reset))
}
return reset, nil
}
// RepairAndRescrapeAllLibraries 修复并重刮所有媒体库:先从媒体路径中的
@@ -135,6 +169,13 @@ func (c *Container) RepairAndRescrapeAllLibraries(ctx context.Context) (RepairAn
}
result.Repaired = repaired
// 重置全库脏的 matched 剧集行(单集 id 污染整剧字段),让其下方重刮一并修正。
if reset, err := c.resetEpisodicMatchedForRescrape(ctx, ""); err != nil {
return result, err
} else {
result.Reset = reset
}
if c.Scraper == nil || c.Repo.Library == nil {
return result, nil
}
@@ -187,6 +228,13 @@ func (c *Container) RepairAndRescrapeLibrary(ctx context.Context, libraryID stri
}
result.Repaired = repaired
// 重置该库脏的 matched 剧集行,让下方重刮修正被单集 id 污染的整剧字段。
if reset, err := c.resetEpisodicMatchedForRescrape(ctx, libraryID); err != nil {
return result, err
} else {
result.Reset = reset
}
if c.Scraper == nil {
return result, nil
}
@@ -0,0 +1,67 @@
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"))
}
}
+121
View File
@@ -443,9 +443,130 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
// 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按
// Season/SxxE 结构整理的内容(如整合成剧集的剧场版 / 合集动画)。这些行若按
// 散装单集(Episode)漏出,在 Infuse/yamby 等客户端表现为「整部剧被拆成一堆
// 单集卡片」。方案 B: 把它们按整剧聚成 Series 卡片,与真正的电影(Movie)并列
// 展示在同一电影库视图。仅在该电影库确实含此类内容时才走此分支,普通电影库
// 仍走 mediaItems(保留其缓存 / 版本合并逻辑)。
if p.ParentID != "" && !p.Recursive && len(p.IncludeItemTypes) == 0 {
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil && !episodic {
if has, err := e.movieLibraryHasEpisodicContent(ctx, p.ParentID); err == nil && has {
return e.movieLibraryItems(ctx, p)
}
}
}
return e.mediaItems(ctx, p)
}
// movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容
// (有季集号且路径形如剧集,例如 .../国产剧/某剧/Season 01/某剧 - S01E01.mkv)。
// 用于决定是否需要走 movieLibraryItems 把这些内容聚成 Series 卡片。普通电影库
// 没有这类行时返回 false,继续走常规 mediaItems。
func (e *EmbyService) movieLibraryHasEpisodicContent(ctx context.Context, libraryID string) (bool, error) {
clause, args := embyLikelyEpisodicPathSQL()
if clause == "" {
return false, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID)).
Where("(season_num > 0 OR episode_num > 0) AND ("+clause+")", args...)
var count int64
if err := q.Limit(1).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// movieLibraryItems 处理电影类型库的常规浏览,返回「真正的电影(Movie)」与
// 「库内剧集结构内容聚成的 Series 卡片」的合并列表(按 DateCreated 倒序分页)。
// 与 mediaItems 的区别: 后者会把剧集结构行当散装 Episode 漏出;这里改为聚合成
// Series,从根本上消除「电影库里整部剧被拆成单集」的现象。
func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
libIDs := e.mergedLibraryIDs(ctx, p.ParentID)
apply := func(q *gorm.DB) *gorm.DB {
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
q = q.Where("library_id IN ?", libIDs)
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
return nil
}
q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID)
}
return q
}
// 剧集结构内容 → Series 卡片。
clause, args := embyLikelyEpisodicPathSQL()
var episodicRows []model.Media
if clause != "" {
epQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
if epQ == nil {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
epQ = epQ.Where("(season_num > 0 OR episode_num > 0) AND ("+clause+")", args...).
Order("media.created_at desc").Limit(embySeriesGroupingLimit)
if err := epQ.Find(&episodicRows).Error; err != nil {
return nil, err
}
}
seriesGroups := e.seriesGroupsFromMedia(episodicRows)
// 真正的电影 → Movie 项(剔除剧集结构行)。
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
if movieQ == nil {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
movieQ = filterLikelyEpisodicPathsFromMovieQuery(movieQ).
Order("media.created_at desc").Limit(embySeriesGroupingLimit)
var movieRows []model.Media
if err := movieQ.Find(&movieRows).Error; err != nil {
return nil, err
}
movieItems, err := e.payloadsForMedia(ctx, movieRows, p.UserID)
if err != nil {
return nil, err
}
// 合并: Series 卡片 + Movie 项, 统一按 DateCreated 倒序。
type entry struct {
createdAt time.Time
payload map[string]any
}
entries := make([]entry, 0, len(seriesGroups)+len(movieItems))
for _, g := range seriesGroups {
entries = append(entries, entry{createdAt: g.CreatedAt, payload: e.seriesPayload(g)})
}
for _, item := range movieItems {
entries = append(entries, entry{createdAt: embyPayloadCreatedAt(item), payload: item})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].createdAt.After(entries[j].createdAt)
})
total := len(entries)
paged := pageSlice(entries, p.StartIndex, p.Limit)
items := make([]map[string]any, 0, len(paged))
for _, en := range paged {
items = append(items, en.payload)
}
return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
// embyPayloadCreatedAt 从 item payload 里取 DateCreated(time.Time),用于合并排序。
func embyPayloadCreatedAt(item map[string]any) time.Time {
if item == nil {
return time.Time{}
}
if v, ok := item["DateCreated"].(time.Time); ok {
return v
}
return time.Time{}
}
func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
cacheKey := e.embyItemsCacheKey("items", p)
var cached embyItemsCacheValue
+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}
@@ -0,0 +1,146 @@
// Package service — one-time cleanup for episode metadata polluted by an older
// scraper bug that wrote per-episode TMDB ids / episode names into the
// series-level fields (tm_db_id / original_name).
package service
import (
"context"
"regexp"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// pollutedEpisodeCleanupSettingKey marks that the one-time normalization has run.
const pollutedEpisodeCleanupSettingKey = "media.polluted_episode_cleanup_done"
// seasonFolderTailRE 去掉路径末尾的「季文件夹 + 文件名」,得到整剧目录(show_dir)。
// 例: /tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv → /tv/国漫/遮天 (2023)
var seasonFolderTailRE = regexp.MustCompile(`(?i)[\\/](?:season[\s._-]*\d+|s\d{1,2}|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)[\\/][^\\/]*$`)
// showDirFromEpisodePath 从单集路径推出整剧目录, 作为「同一部剧」的聚合键。
// 若没有季文件夹, 则退而去掉文件名取其父目录。
func showDirFromEpisodePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if loc := seasonFolderTailRE.FindStringIndex(path); loc != nil {
return path[:loc[0]]
}
// 无季文件夹: 取父目录(去掉最后一段文件名)。
sep := strings.LastIndexAny(path, `/\`)
if sep <= 0 {
return ""
}
return path[:sep]
}
// NormalizePollutedEpisodeMetadata 一次性清洗历史脏数据:老版本刮削曾把
// 【单集 episode id】写进整剧 tm_db_id、把单集名写进 original_name, 导致同一部剧
// 每集 id/原名各不相同, 被前端 / Emby 拆成 N 张单集卡(实测「遮天」90 集 = 89 个
// 不同 tm_db_id)。
//
// 处理方式: 按「library_id + 整剧目录(show_dir)」聚合有季集号的行;当一组内出现
// 「多个不同的非零 tm_db_id」或「多个不同的 original_name」时, 判定该组被单集级
// 数据污染, 将该组所有行的 tm_db_id 清零、original_name 清空, 并把 scrape_status
// 重置为 pending —— 让其借「修复+重刮」写回正确的整剧 id/原名。即便在重刮前,
// 前端 / Emby 也已改为「路径剧名优先」分组, 清空脏字段后即可正确合并成一张卡。
//
// 幂等: 完成后写入 setting 标记;已标记则直接跳过。仅处理剧集类(有季集号)行,
// 不触碰电影。
func (c *Container) NormalizePollutedEpisodeMetadata(ctx context.Context) (int, error) {
if c == nil || c.Repo == nil || c.Repo.DB == nil {
return 0, nil
}
if c.Repo.Setting != nil {
if v, err := c.Repo.Setting.Get(ctx, pollutedEpisodeCleanupSettingKey); err == nil && strings.TrimSpace(v) == "true" {
return 0, nil
}
}
db := c.Repo.DB.WithContext(ctx)
// 聚合状态: 每个 show_dir 收集 行 id、非零 tmdb 集合、original_name 集合。
type agg struct {
ids []string
tmdbSet map[int]struct{}
origSet map[string]struct{}
}
groups := map[string]*agg{}
var rows []model.Media
err := db.Model(&model.Media{}).
Select("id, library_id, original_name, season_num, episode_num, tm_db_id, path").
Where("season_num > 0 OR episode_num > 0").
FindInBatches(&rows, 1000, func(_ *gorm.DB, _ int) error {
for i := range rows {
row := rows[i]
showDir := showDirFromEpisodePath(row.Path)
if showDir == "" {
continue
}
key := strings.ToLower(strings.TrimSpace(row.LibraryID)) + "|" + strings.ToLower(showDir)
g := groups[key]
if g == nil {
g = &agg{tmdbSet: map[int]struct{}{}, origSet: map[string]struct{}{}}
groups[key] = g
}
g.ids = append(g.ids, row.ID)
if row.TMDbID > 0 {
g.tmdbSet[row.TMDbID] = struct{}{}
}
if name := strings.TrimSpace(row.OriginalName); name != "" {
g.origSet[name] = struct{}{}
}
}
return nil
}).Error
if err != nil {
return 0, err
}
cleaned := 0
for _, g := range groups {
// 被单集数据污染的判据: 同一部剧出现多个不同的非零 tmdb id,
// 或多个不同的 original_name(整剧原名本应全组一致)。
polluted := len(g.tmdbSet) > 1 || len(g.origSet) > 1
if !polluted || len(g.ids) == 0 {
continue
}
// 分批更新(避免 IN 列表过长)。
const chunk = 400
for start := 0; start < len(g.ids); start += chunk {
end := start + chunk
if end > len(g.ids) {
end = len(g.ids)
}
res := db.Model(&model.Media{}).
Where("id IN ?", g.ids[start:end]).
Updates(map[string]any{
"tm_db_id": 0,
"original_name": "",
"scrape_status": "pending",
})
if res.Error != nil {
return cleaned, res.Error
}
cleaned += int(res.RowsAffected)
}
}
if c.Repo.Setting != nil {
if err := c.Repo.Setting.Set(ctx, pollutedEpisodeCleanupSettingKey, "true"); err != nil && c.Log != nil {
c.Log.Warn("mark polluted episode cleanup done failed", zap.Error(err))
}
}
if cleaned > 0 && c.Log != nil {
c.Log.Info("polluted episode metadata normalized",
zap.Int("rows", cleaned),
zap.Int("groups", len(groups)))
}
return cleaned, nil
}
@@ -0,0 +1,102 @@
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"
)
func newCleanupTestContainer(t *testing.T) (*Container, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(&model.Media{}, &model.Setting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
return &Container{Repo: repos, Log: zap.NewNop()}, db
}
func TestShowDirFromEpisodePath(t *testing.T) {
cases := []struct{ in, want string }{
{`/tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv`, `/tv/国漫/遮天 (2023)`},
{`cloud://drive/国漫/武神主宰 (2020)/S01/武神主宰 - S01E05.mkv`, `cloud://drive/国漫/武神主宰 (2020)`},
{`/tv/某剧/第 2 季/某剧 第05集.mkv`, `/tv/某剧`},
// 无季文件夹: 取父目录。
{`/tv/某剧/某剧 - S01E01.mkv`, `/tv/某剧`},
}
for _, tc := range cases {
if got := showDirFromEpisodePath(tc.in); got != tc.want {
t.Errorf("showDirFromEpisodePath(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestNormalizePollutedEpisodeMetadata 验证: 被单集 id/名污染的剧组被清洗,
// 干净的剧组不动, 且迁移幂等(写入标记后再跑不重复处理)。
func TestNormalizePollutedEpisodeMetadata(t *testing.T) {
container, db := newCleanupTestContainer(t)
rows := []model.Media{
// 污染组: 同一部剧每集 tmdb 不同、original_name 是单集名 → 应清洗。
{Base: model.Base{ID: "z1"}, LibraryID: "lib", SeasonNum: 1, EpisodeNum: 1, TMDbID: 4375419, OriginalName: "九龙拉棺", ScrapeStatus: "matched", Path: `/tv/国漫/遮天 (2023)/Season 01/遮天 - S01E01.mkv`},
{Base: model.Base{ID: "z2"}, LibraryID: "lib", SeasonNum: 1, EpisodeNum: 2, TMDbID: 4375461, OriginalName: "星空古路", ScrapeStatus: "matched", Path: `/tv/国漫/遮天 (2023)/Season 01/遮天 - S01E02.mkv`},
// 干净组: 全组共享同一整剧 tmdb、original_name 一致 → 不动。
{Base: model.Base{ID: "c1"}, LibraryID: "lib", SeasonNum: 1, EpisodeNum: 1, TMDbID: 1234, OriginalName: "Clean Show", ScrapeStatus: "matched", Path: `/tv/欧美剧/Clean Show (2020)/Season 01/Clean Show - S01E01.mkv`},
{Base: model.Base{ID: "c2"}, LibraryID: "lib", SeasonNum: 1, EpisodeNum: 2, TMDbID: 1234, OriginalName: "Clean Show", ScrapeStatus: "matched", Path: `/tv/欧美剧/Clean Show (2020)/Season 01/Clean Show - S01E02.mkv`},
}
for i := range rows {
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("create %s: %v", rows[i].ID, err)
}
}
cleaned, err := container.NormalizePollutedEpisodeMetadata(t.Context())
if err != nil {
t.Fatalf("normalize: %v", err)
}
if cleaned != 2 {
t.Fatalf("cleaned = %d, want 2 (only the polluted show)", cleaned)
}
load := func(id string) model.Media {
var m model.Media
if err := db.First(&m, "id = ?", id).Error; err != nil {
t.Fatalf("load %s: %v", id, err)
}
return m
}
// 污染组被清空字段并重置 pending。
for _, id := range []string{"z1", "z2"} {
m := load(id)
if m.TMDbID != 0 || m.OriginalName != "" || m.ScrapeStatus != "pending" {
t.Fatalf("polluted row %s not cleaned: %+v", id, m)
}
}
// 干净组保持不变。
for _, id := range []string{"c1", "c2"} {
m := load(id)
if m.TMDbID != 1234 || m.OriginalName != "Clean Show" || m.ScrapeStatus != "matched" {
t.Fatalf("clean row %s should be untouched: %+v", id, m)
}
}
// 幂等: 第二次运行不再处理(已写标记)。
cleaned2, err := container.NormalizePollutedEpisodeMetadata(t.Context())
if err != nil {
t.Fatalf("normalize again: %v", err)
}
if cleaned2 != 0 {
t.Fatalf("second run should be a no-op, cleaned=%d", cleaned2)
}
}
+25 -24
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 != "" && episodeTitle != "" && episodeTitle != dst.Title {
dst.OriginalName = episodeTitle
} else if dst.Title == "" && episodeTitle != "" {
} else if dst.Title == "" {
if episodeTitle := cleanXMLText(doc.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
}
}
+12 -3
View File
@@ -61,7 +61,9 @@ func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) {
if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow><title>正确剧名</title><year>2024</year><tmdbid>123</tmdbid></tvshow>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>第三集</title><season>2</season><episode>3</episode><plot>本集简介</plot></episodedetails>`), 0o644); err != nil {
// 单集 NFO 携带【单集级】tmdb id(4375419)与单集名(第三集):二者都不得
// 覆盖整剧字段,否则同剧各集 id/原名互不相同会被拆成多张卡。
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<episodedetails><title>第三集</title><season>2</season><episode>3</episode><plot>本集简介</plot><uniqueid type="tmdb">4375419</uniqueid></episodedetails>`), 0o644); err != nil {
t.Fatal(err)
}
@@ -69,9 +71,14 @@ func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got == nil || got.Title != "正确剧名" || got.OriginalName != "第三集" || got.SeasonNum != 2 || got.EpisodeNum != 3 {
if got == nil || got.Title != "正确剧名" || got.SeasonNum != 2 || got.EpisodeNum != 3 {
t.Fatalf("unexpected metadata: %+v", got)
}
// 单集名不得写入整剧原名(分组键)。
if got.OriginalName != "" {
t.Fatalf("episode title must not pollute OriginalName, got %q", got.OriginalName)
}
// 单集级简介按集回填;整剧 tmdb 仍取 tvshow.nfo 的 123,单集 id 不得覆盖。
if got.Overview != "本集简介" || got.TMDbID != 123 {
t.Fatalf("episode/show merge failed: %+v", got)
}
@@ -464,7 +471,9 @@ func TestScanLibraryUsesLocalMetadata(t *testing.T) {
if err := db.First(&media, "path = ?", mediaPath).Error; err != nil {
t.Fatal(err)
}
if media.Title != "本地剧名" || media.OriginalName != "本地第三集" || media.SeasonNum != 2 || media.EpisodeNum != 3 || media.ScrapeStatus != "matched" {
// 单集名(本地第三集)不应写入 OriginalName(整剧原名,合集分组键)。
// tvshow.nfo 未提供 originaltitle, 故 OriginalName 应为空。
if media.Title != "本地剧名" || media.OriginalName != "" || media.SeasonNum != 2 || media.EpisodeNum != 3 || media.ScrapeStatus != "matched" {
t.Fatalf("unexpected scanned media: %+v", media)
}
+3 -1
View File
@@ -580,7 +580,9 @@ func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) {
if err := repos.DB.First(&media).Error; err != nil {
t.Fatal(err)
}
if media.Title != "剑来" || media.OriginalName != "第一集" || media.Year != 2024 {
// 单集名(episode <title>「第一集」)不得写入 OriginalName(整剧原名/分组键)。
// tvshow.nfo 未提供 originaltitle, 故 OriginalName 应为空。
if media.Title != "剑来" || media.OriginalName != "" || media.Year != 2024 {
t.Fatalf("metadata not applied: %#v", media)
}
if media.SeasonNum != 1 || media.EpisodeNum != 1 {
+27 -8
View File
@@ -9,28 +9,47 @@ import type { Media } from '../types'
*
* 折叠键优先级(命中第一个就分组):
*
* 1. series_id (后端某些场景下会预先聚合)
* 2. 有季/集信息时用 library_id + 节目名(综艺每集常有不同 TMDB episode id)
* 3. tmdb_id / bangumi_id(电影或无季集条目)
* 4. library_id + title (fallback:同库同名视为同一剧)
* 剧集(有季集号 / 路径像剧集):
* 1. library_id + 路径剧名 ← 最稳定:同一部剧的各集都在同一剧目录下
* 2. tmdb_id / bangumi_id / douban / thetvdb (无路径剧名时兜底)
* 3. series_id (后端预聚合,目前仅 Emby 虚拟分组用,DB 一般为空)
* 4. library_id + title (最终兜底)
* 电影:
* 1. tmdb_id / bangumi_id
* 2. library_id + 路径剧名
* 3. library_id + title
*
* 为什么剧集要把「路径剧名」放在 tmdb_id 之前:
* 本地/网盘按 MoviePilot 整理的剧集每集旁带 episode NFO, 其 <uniqueid type="tmdb">
* 是【单集 episode id】(每集都不同)。若按 tmdb_id 分组, 同一部剧 N 集会被拆成 N
* 张卡(实测「遮天」90 集 = 89 个不同 tmdb_id)。而整剧目录名对全剧一致, 是最可靠的
* 分组依据, 且对「部分集已刮削、部分未刮削」也能稳定合并。
*
* key 必须是「单条 media 的纯函数」且与子集无关——它会被写进卡片链接
* (seriesCardLink → ?series=KEY), 之后 LibraryPage 用 getSeriesKey(m)===KEY 反查
* 该剧的所有集。路径剧名优先正好满足:同剧各集的 key 恒等, 跨页面/子集稳定。
*
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
*/
export type SeriesCard = { key: string; rep: Media; linkMedia: Media; count: number }
export function getSeriesKey(media: Media): string {
if (media.series_id) return `series:${media.series_id}`
const fromPath = seriesTitleFromPath(media.path)
if (isEpisodeLike(media)) {
if (isEpisodeLike(media) || pathLooksEpisodic(media)) {
// 路径剧名优先:对全剧一致, 不受单集 tmdb 污染影响。
if (fromPath) return `lib:${targetLibraryID(media)}|show:${fromPath}`
// 无路径剧名(扁平目录)时才退而用外部 id;此时各集若共享整剧 id 仍能合并。
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (media.douban_id) return `douban:${media.douban_id}`
if (media.thetvdb_id) return `thetvdb:${media.thetvdb_id}`
return `lib:${targetLibraryID(media)}|show:${normalizeTitle(fromPath || seriesTitle(media))}`
if (media.series_id) return `series:${media.series_id}`
return `lib:${targetLibraryID(media)}|show:${normalizeTitle(seriesTitle(media))}`
}
if (media.series_id) return `series:${media.series_id}`
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
if (fromPath) return `lib:${media.library_id}|show:${normalizeTitle(fromPath)}`
if (fromPath) return `lib:${media.library_id}|show:${fromPath}`
return `lib:${media.library_id}|${normalizeTitle(media.title)}`
}