mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 23:36:37 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user