Files
MeBox/internal/service/emby_items_list.go
T
2026-09-18 21:55:12 +08:00

291 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
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
}