package service import ( "context" "sort" "strings" "time" "github.com/truewhile/MeBox/internal/model" ) func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) { cacheKey := e.embyItemsCacheKey("items", p) var cached embyItemsCacheValue if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) { return map[string]any{"Items": cached.Items, "TotalRecordCount": cached.TotalRecordCount, "StartIndex": cached.StartIndex}, nil } q := e.repo.DB.WithContext(ctx).Model(&model.Media{}) q = e.applyUserMediaVisibility(ctx, q, p.UserID) if p.ParentID != "" { q = q.Where("library_id IN ? OR series_id = ?", e.mergedLibraryIDs(ctx, p.ParentID), p.ParentID) } 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 map[string]any{"Items": []map[string]any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil } q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID) } resumeFilter := containsEmbyFilter(p.Filters, "IsResumable") if resumeFilter { if strings.TrimSpace(p.UserID) == "" { return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil } q = q.Joins(`JOIN ( SELECT media_id, MAX(watched_at) AS watched_at FROM playback_histories WHERE user_id = ? AND completed = ? AND position_ms > 0 GROUP BY media_id ) AS resume ON resume.media_id = media.id`, p.UserID, false) } filterBySeasonNumbers := true parentKnownNonEpisodic := false if p.ParentID != "" { if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil && !episodic { filterBySeasonNumbers = false parentKnownNonEpisodic = true } } if parentKnownNonEpisodic && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") { return emptyItemsEnvelope(p.StartIndex), nil } if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") { q = e.filterMovieItems(ctx, q) } if parentKnownNonEpisodic && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") { q = filterLikelyEpisodicPathsFromMovieQuery(q) } if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") { q = e.filterEpisodeItems(ctx, q) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, err } desc := !strings.EqualFold(firstCSVValue(p.SortOrder), "Ascending") order := mediaReleaseOrderSQL(true) orderIncludesDirection := true switch primarySupportedEmbySort(p.SortBy, resumeFilter) { case "sortname", "name": order = "media.title" orderIncludesDirection = false case "premieredate", "productionyear": order = mediaReleaseOrderSQL(desc) case "datecreated", "datelastmediaadded", "datelastcontentadded": order = "media.created_at" orderIncludesDirection = false case "dateplayed": order = "resume.watched_at" orderIncludesDirection = false case "communityrating": order = "media.rating" orderIncludesDirection = false } if !orderIncludesDirection && strings.EqualFold(firstCSVValue(p.SortOrder), "Descending") { if !strings.HasSuffix(order, " desc") { order = order + " desc" } } fetchLimit := p.Limit fetchOffset := p.StartIndex if fetchLimit > 0 && e.shouldCollapseMediaVersions(ctx, p) { // Duplicates across merged local/cloud libraries collapse into one Emby // item with multiple MediaSources. Fetch a wider window so duplicates do // not consume the whole requested page. fetchOffset = 0 fetchLimit = p.StartIndex + maxInt(p.Limit*4, p.Limit) } var rows []model.Media if err := q.Order(order).Offset(fetchOffset).Limit(fetchLimit).Find(&rows).Error; err != nil { return nil, err } if e.shouldCollapseMediaVersions(ctx, p) { rows = e.collapseMediaVersionRows(ctx, rows) rows = pageSlice(rows, p.StartIndex, p.Limit) } items, err := e.payloadsForMedia(ctx, rows, p.UserID) if err != nil { return nil, err } out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex} if e.cache != nil { e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{Items: items, TotalRecordCount: total, StartIndex: p.StartIndex}, time.Duration(e.mediaCacheTTLSeconds())*time.Second) } return out, nil } func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p ItemsParams) (map[string]any, error) { rows = e.filterMediaRowsForUser(ctx, rows, p.UserID) // rows 可能来自 series 分组的内存 memo(embySeriesGroup.Episodes)。过滤必须 // 分配新切片:就地复用 rows[:0] 会覆写 memo 里的元素,让后续请求看到被前一次 // 过滤污染的剧集列表(例如按季筛选一次之后,特别篇就从缓存分组里消失了)。 if p.SeasonIndex != nil { filtered := make([]model.Media, 0, len(rows)) for i := range rows { if embyRowMatchesSeasonIndex(&rows[i], p.SeasonIndex) { filtered = append(filtered, rows[i]) } } rows = filtered } if p.SearchTerm != "" { needle := strings.ToLower(p.SearchTerm) filtered := make([]model.Media, 0, len(rows)) for i := range rows { if strings.Contains(strings.ToLower(rows[i].Title), needle) || strings.Contains(strings.ToLower(rows[i].OriginalName), needle) { filtered = append(filtered, rows[i]) } } rows = filtered } // sort.SliceStable 同样会就地重排:先拷贝一份,避免把 memo 分组里的剧集顺序 // 按每次请求的分页/筛选结果固定下来。 rows = append([]model.Media(nil), rows...) sort.SliceStable(rows, func(i, j int) bool { if rows[i].SeasonNum != rows[j].SeasonNum { return rows[i].SeasonNum < rows[j].SeasonNum } if rows[i].EpisodeNum != rows[j].EpisodeNum { return rows[i].EpisodeNum < rows[j].EpisodeNum } return rows[i].CreatedAt.Before(rows[j].CreatedAt) }) // 先折叠同集的多个版本再统计总数与分页,与 payloadsForMedia 内部保持同一步骤。 // 若按未折叠的行数报 TotalRecordCount(例如 S01E11 与 S01E11.5 两行折叠成一条), // 客户端会认为还有一条没取到,反复请求下一页 —— 分集列表就会一直加载不出来。 rows = e.collapseMediaVersionRows(ctx, rows) total := len(rows) items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID) if err != nil { return nil, err } return map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil } func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) { // 请求级缓存:库类型与 series 标题整页只查一次,消除逐条目 N+1。 ctx = e.withPayloadCache(ctx) e.prefetchPayloadCache(ctx, rows) rows = e.collapseMediaVersionRows(ctx, rows) userFavs := map[string]bool{} userPos := map[string]int64{} if userID != "" && len(rows) > 0 { mediaIDs := make([]string, 0, len(rows)) for _, row := range rows { if strings.TrimSpace(row.ID) != "" { mediaIDs = append(mediaIDs, row.ID) } } if len(mediaIDs) == 0 { mediaIDs = []string{"__none__"} } var favs []model.Favorite favQuery := e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Where("media_id IN ?", mediaIDs) _ = favQuery.Find(&favs).Error for _, f := range favs { userFavs[f.MediaID] = true } var hist []model.PlaybackHistory histQuery := e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Where("media_id IN ?", mediaIDs) _ = histQuery.Find(&hist).Error for _, h := range hist { userPos[h.MediaID] = h.PositionMs } } items := make([]map[string]any, 0, len(rows)) for _, m := range rows { items = append(items, e.itemPayload(ctx, &m, userFavs[m.ID], userPos[m.ID], false)) } return items, nil } func (e *EmbyService) shouldCollapseMediaVersions(ctx context.Context, p ItemsParams) bool { if containsItemType(p.IncludeItemTypes, "Series") || containsItemType(p.IncludeItemTypes, "Season") { return false } if containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") { return true } if p.ParentID == "" { return true } episodic, err := e.libraryIsEpisodic(ctx, p.ParentID) return err == nil && !episodic } func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model.Media) []model.Media { if len(rows) < 2 { return rows } out := make([]model.Media, 0, len(rows)) indexByKey := make(map[string]int, len(rows)) for _, row := range rows { key := e.mediaVersionKey(ctx, &row) if key == "" { out = append(out, row) continue } if idx, ok := indexByKey[key]; ok { if preferMediaVersion(row, out[idx]) { out[idx] = row } continue } indexByKey[key] = len(out) out = append(out, row) } return out } func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID string, p ItemsParams) (map[string]any, error) { cacheKey := e.embyItemsCacheKey("series-items-v1", p) var cached embyItemsCacheValue if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) { e.rememberArtworkRefs(cached.Artwork) return map[string]any{ "Items": cached.Items, "TotalRecordCount": int(cached.TotalRecordCount), "StartIndex": cached.StartIndex, }, nil } q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0") q = e.applyUserMediaVisibility(ctx, q, p.UserID) if libraryID != "" { q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID)) } 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 map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil } q = q.Joins("JOIN favorites ON favorites.media_id = media.id AND favorites.user_id = ? AND favorites.deleted_at IS NULL", p.UserID) } var rows []model.Media if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return nil, err } groups := e.seriesGroupsFromMedia(ctx, rows) sortSeriesGroups(groups, p) total := len(groups) pageGroups := pageSlice(groups, p.StartIndex, p.Limit) items := make([]map[string]any, 0, len(pageGroups)) for _, group := range pageGroups { items = append(items, e.seriesPayload(group)) } out := map[string]any{"Items": items, "TotalRecordCount": total, "StartIndex": p.StartIndex} if e.cache != nil { e.cache.SetJSON(ctx, cacheKey, embyItemsCacheValue{ Items: items, TotalRecordCount: int64(total), StartIndex: p.StartIndex, Artwork: e.artworkRefsForSeriesGroups(pageGroups), }, time.Duration(e.mediaCacheTTLSeconds())*time.Second) } return out, nil }