From dff19801b67d5a7ad3d4c4485d168a8b80882863 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:04:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(series):=20=E5=BD=BB=E5=BA=95=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=89=A7=E9=9B=86=E8=A2=AB=E6=8C=89=E5=8D=95=E9=9B=86?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=B8=94=E6=97=A0=E6=B3=95=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(用真实数据确诊, 两个互相独立): - Web 端拆集: episode NFO 的 是【单集 episode id】、 是【单集名】, 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 中依赖旧错误行为的用例。 --- cmd/server/main.go | 8 + internal/handler/repair_rescrape.go | 2 + internal/service/cloud_path_repair.go | 48 ++++++ internal/service/cloud_path_repair_test.go | 67 ++++++++ internal/service/emby_compat.go | 121 +++++++++++++++ internal/service/emby_compat_test.go | 96 ++++++++++++ internal/service/episode_metadata_cleanup.go | 146 ++++++++++++++++++ .../service/episode_metadata_cleanup_test.go | 102 ++++++++++++ internal/service/local_metadata.go | 51 +++--- internal/service/local_metadata_test.go | 15 +- internal/service/scanner_cloud_test.go | 4 +- web/src/utils/groupSeries.ts | 35 ++++- 12 files changed, 658 insertions(+), 37 deletions(-) create mode 100644 internal/service/cloud_path_repair_test.go create mode 100644 internal/service/episode_metadata_cleanup.go create mode 100644 internal/service/episode_metadata_cleanup_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 80e4e08..21f3fb8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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)) } diff --git a/internal/handler/repair_rescrape.go b/internal/handler/repair_rescrape.go index e37163e..92fb1c5 100644 --- a/internal/handler/repair_rescrape.go +++ b/internal/handler/repair_rescrape.go @@ -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 := "媒体库修复并重刮完成" diff --git a/internal/service/cloud_path_repair.go b/internal/service/cloud_path_repair.go index 2ed4f9f..6e0e554 100644 --- a/internal/service/cloud_path_repair.go +++ b/internal/service/cloud_path_repair.go @@ -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 } diff --git a/internal/service/cloud_path_repair_test.go b/internal/service/cloud_path_repair_test.go new file mode 100644 index 0000000..51c2ec1 --- /dev/null +++ b/internal/service/cloud_path_repair_test.go @@ -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")) + } +} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 19ee565..09cdaf0 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -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 diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index db24f6d..5454054 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -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} diff --git a/internal/service/episode_metadata_cleanup.go b/internal/service/episode_metadata_cleanup.go new file mode 100644 index 0000000..9665cb0 --- /dev/null +++ b/internal/service/episode_metadata_cleanup.go @@ -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 +} diff --git a/internal/service/episode_metadata_cleanup_test.go b/internal/service/episode_metadata_cleanup_test.go new file mode 100644 index 0000000..cef4395 --- /dev/null +++ b/internal/service/episode_metadata_cleanup_test.go @@ -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) + } +} diff --git a/internal/service/local_metadata.go b/internal/service/local_metadata.go index 8d8bb08..fdc7da4 100644 --- a/internal/service/local_metadata.go +++ b/internal/service/local_metadata.go @@ -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 } } diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go index 6ba1213..a707b2a 100644 --- a/internal/service/local_metadata_test.go +++ b/internal/service/local_metadata_test.go @@ -61,7 +61,9 @@ func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) { if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`<tvshow><title>正确剧名2024123`), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(nfoPath(mediaPath), []byte(`第三集23本集简介`), 0o644); err != nil { + // 单集 NFO 携带【单集级】tmdb id(4375419)与单集名(第三集):二者都不得 + // 覆盖整剧字段,否则同剧各集 id/原名互不相同会被拆成多张卡。 + if err := os.WriteFile(nfoPath(mediaPath), []byte(`第三集23本集简介4375419`), 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) } diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go index d6da3dd..b759277 100644 --- a/internal/service/scanner_cloud_test.go +++ b/internal/service/scanner_cloud_test.go @@ -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 「第一集」)不得写入 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 { diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index e70fe3c..2e90e5e 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -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)}` }