mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
298 lines
11 KiB
Go
298 lines
11 KiB
Go
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
|
||
}
|
||
if rows[i].EpisodeFraction != rows[j].EpisodeFraction {
|
||
return rows[i].EpisodeFraction < rows[j].EpisodeFraction
|
||
}
|
||
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
|
||
}
|