mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
e8c710c261
Unify favourite writes through SyncUserFavorite so remote mounted media is stored locally and proxied to the upstream Emby server. Emby clients now query favourites from the local table (including remote pseudo IDs), and remote item payloads merge local IsFavorite state. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
487 lines
15 KiB
Go
487 lines
15 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
)
|
|
|
|
// Item 单条目详情。
|
|
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
|
if e == nil {
|
|
return nil, nil
|
|
}
|
|
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
|
|
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
|
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
|
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
|
if mount == nil || acct == nil {
|
|
return nil, nil
|
|
}
|
|
out, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
|
if err != nil || out == nil {
|
|
return out, err
|
|
}
|
|
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
|
return nil, err
|
|
}
|
|
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
|
|
userData, _ := out["UserData"].(map[string]any)
|
|
if userData == nil {
|
|
userData = map[string]any{}
|
|
out["UserData"] = userData
|
|
}
|
|
userData["IsFavorite"] = true
|
|
}
|
|
return out, nil
|
|
}
|
|
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
|
|
return nil, err
|
|
} else if lib != nil {
|
|
libs := FilterDisplayCloudLibraries(ctx, e.repo, []model.Library{*lib})
|
|
if len(libs) == 0 {
|
|
return nil, nil
|
|
}
|
|
visibility := e.mediaVisibility(ctx, userID)
|
|
if !e.libraryVisibleFromCachedVisibility(libs[0], visibility) {
|
|
return nil, nil
|
|
}
|
|
return e.libraryAsView(ctx, &libs[0]), nil
|
|
}
|
|
if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
|
|
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return e.seasonPayload(season), nil
|
|
}
|
|
}
|
|
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
|
|
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return e.seriesPayload(series), nil
|
|
}
|
|
}
|
|
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if m == nil {
|
|
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return e.seriesPayload(series), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
if !UserDefaultMediaVisibility(ctx, e.repo, userID).Allows(m) {
|
|
return nil, nil
|
|
}
|
|
fav := false
|
|
pos := int64(0)
|
|
if userID != "" {
|
|
var f model.Favorite
|
|
ferr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
|
|
if ferr == nil {
|
|
fav = true
|
|
}
|
|
var h model.PlaybackHistory
|
|
herr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).
|
|
Order("watched_at desc").First(&h).Error
|
|
if herr == nil {
|
|
pos = h.PositionMs
|
|
}
|
|
}
|
|
return e.itemPayload(ctx, m, fav, pos), nil
|
|
}
|
|
|
|
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
|
|
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
if e.remote != nil && IsEmbyRemoteID(parentID) {
|
|
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
|
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
|
if mount == nil || acct == nil {
|
|
return nil, nil
|
|
}
|
|
out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
|
var cached embyLatestCacheValue
|
|
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
|
return cached.Items, nil
|
|
}
|
|
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
|
if parentID != "" {
|
|
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
|
|
out, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
|
|
if err == nil && e.cache != nil {
|
|
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
|
}
|
|
return out, err
|
|
}
|
|
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
|
|
}
|
|
rowLimit := limit * 4
|
|
if rowLimit < 100 {
|
|
rowLimit = 100
|
|
}
|
|
if rowLimit > 500 {
|
|
rowLimit = 500
|
|
}
|
|
var rows []model.Media
|
|
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
rows = e.collapseMediaVersionRows(ctx, rows)
|
|
if len(rows) > limit {
|
|
rows = rows[:limit]
|
|
}
|
|
out, err := e.payloadsForMedia(ctx, rows, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if e.cache != nil {
|
|
e.cache.SetJSON(ctx, cacheKey, embyLatestCacheValue{Items: out}, time.Duration(e.mediaCacheTTLSeconds())*time.Second)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
|
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
|
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
|
var rows []model.Media
|
|
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
groups := e.seriesGroupsFromMedia(rows)
|
|
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
|
if len(groups) > limit {
|
|
groups = groups[:limit]
|
|
}
|
|
items := make([]map[string]any, 0, len(groups))
|
|
for _, group := range groups {
|
|
items = append(items, e.seriesPayload(group))
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// ResumeItems 列出有未完成播放进度的媒体。
|
|
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
|
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
|
}
|
|
|
|
// favoriteItems returns favourited media for Emby clients, including mounted
|
|
// remote items stored only in the local favourites table.
|
|
func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
|
if p.Limit <= 0 || p.Limit > 500 {
|
|
p.Limit = 50
|
|
}
|
|
if p.StartIndex < 0 {
|
|
p.StartIndex = 0
|
|
}
|
|
if strings.TrimSpace(p.UserID) == "" {
|
|
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
|
}
|
|
|
|
var favs []model.Favorite
|
|
if err := e.repo.DB.WithContext(ctx).
|
|
Where("user_id = ?", p.UserID).
|
|
Order("created_at desc").
|
|
Find(&favs).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(favs) == 0 {
|
|
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
|
}
|
|
|
|
localIDs := make([]string, 0, len(favs))
|
|
for _, fav := range favs {
|
|
if !IsEmbyRemoteID(fav.MediaID) {
|
|
localIDs = append(localIDs, fav.MediaID)
|
|
}
|
|
}
|
|
byID := map[string]*model.Media{}
|
|
if len(localIDs) > 0 {
|
|
var medias []model.Media
|
|
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
|
|
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
|
if err := q.Find(&medias).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range medias {
|
|
byID[medias[i].ID] = &medias[i]
|
|
}
|
|
}
|
|
|
|
items := make([]map[string]any, 0, len(favs))
|
|
for _, fav := range favs {
|
|
if m, ok := byID[fav.MediaID]; ok {
|
|
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, m.LibraryID, m.SeriesID, nil) {
|
|
continue
|
|
}
|
|
if p.SearchTerm != "" {
|
|
needle := strings.ToLower(p.SearchTerm)
|
|
if !strings.Contains(strings.ToLower(m.Title), needle) &&
|
|
!strings.Contains(strings.ToLower(m.OriginalName), needle) {
|
|
continue
|
|
}
|
|
}
|
|
items = append(items, e.itemPayload(ctx, m, true, 0))
|
|
continue
|
|
}
|
|
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
|
|
continue
|
|
}
|
|
mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID)
|
|
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
|
if err != nil || mount == nil || acct == nil {
|
|
continue
|
|
}
|
|
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
|
if err != nil || item == nil {
|
|
continue
|
|
}
|
|
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, "", "", item) {
|
|
continue
|
|
}
|
|
if p.SearchTerm != "" {
|
|
needle := strings.ToLower(p.SearchTerm)
|
|
name, _ := item["Name"].(string)
|
|
orig, _ := item["OriginalTitle"].(string)
|
|
if !strings.Contains(strings.ToLower(name), needle) &&
|
|
!strings.Contains(strings.ToLower(orig), needle) {
|
|
continue
|
|
}
|
|
}
|
|
userData, _ := item["UserData"].(map[string]any)
|
|
if userData == nil {
|
|
userData = map[string]any{}
|
|
item["UserData"] = userData
|
|
}
|
|
userData["IsFavorite"] = true
|
|
items = append(items, item)
|
|
}
|
|
|
|
total := int64(len(items))
|
|
if p.StartIndex >= len(items) {
|
|
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
|
}
|
|
end := minInt(p.StartIndex+p.Limit, len(items))
|
|
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
|
}
|
|
|
|
func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaID, libraryID, seriesID string, remoteItem map[string]any) bool {
|
|
if parentID == "" {
|
|
return true
|
|
}
|
|
if libraryID != "" {
|
|
if libraryID == parentID || seriesID == parentID {
|
|
return true
|
|
}
|
|
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
|
|
if id == libraryID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if remoteItem == nil {
|
|
return false
|
|
}
|
|
itemParent, _ := remoteItem["ParentId"].(string)
|
|
itemSeries, _ := remoteItem["SeriesId"].(string)
|
|
if itemParent == parentID || itemSeries == parentID || mediaID == parentID {
|
|
return true
|
|
}
|
|
if !IsEmbyRemoteID(parentID) {
|
|
return false
|
|
}
|
|
wantMountID, _, _ := DecodeEmbyRemoteID(parentID)
|
|
gotMountID, _, _ := DecodeEmbyRemoteID(mediaID)
|
|
return wantMountID != "" && gotMountID == wantMountID
|
|
}
|
|
|
|
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
|
|
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
|
if p.Limit <= 0 || p.Limit > 100 {
|
|
p.Limit = 50
|
|
}
|
|
if p.StartIndex < 0 {
|
|
p.StartIndex = 0
|
|
}
|
|
if strings.TrimSpace(p.UserID) == "" {
|
|
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
|
}
|
|
|
|
var hist []model.PlaybackHistory
|
|
if err := e.repo.DB.WithContext(ctx).
|
|
Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false).
|
|
Order("watched_at desc").Find(&hist).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(hist) == 0 {
|
|
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
|
}
|
|
|
|
localIDs := make([]string, 0, len(hist))
|
|
for _, h := range hist {
|
|
if !IsEmbyRemoteID(h.MediaID) {
|
|
localIDs = append(localIDs, h.MediaID)
|
|
}
|
|
}
|
|
byID := map[string]*model.Media{}
|
|
if len(localIDs) > 0 {
|
|
var medias []model.Media
|
|
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
|
|
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
|
if err := q.Find(&medias).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range medias {
|
|
byID[medias[i].ID] = &medias[i]
|
|
}
|
|
}
|
|
|
|
items := make([]map[string]any, 0, len(hist))
|
|
for _, h := range hist {
|
|
if m, ok := byID[h.MediaID]; ok {
|
|
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
|
|
continue
|
|
}
|
|
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
|
|
continue
|
|
}
|
|
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
|
continue
|
|
}
|
|
mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID)
|
|
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
|
if err != nil || mount == nil || acct == nil {
|
|
continue
|
|
}
|
|
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
|
if err != nil || item == nil {
|
|
continue
|
|
}
|
|
if p.ParentID != "" {
|
|
parentID, _ := item["ParentId"].(string)
|
|
seriesID, _ := item["SeriesId"].(string)
|
|
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
|
|
continue
|
|
}
|
|
}
|
|
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
|
|
items = append(items, item)
|
|
}
|
|
|
|
total := int64(len(items))
|
|
if p.StartIndex >= len(items) {
|
|
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
|
}
|
|
end := minInt(p.StartIndex+p.Limit, len(items))
|
|
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
|
}
|
|
|
|
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
|
|
itemType := "Movie"
|
|
name := m.Title
|
|
parentID := m.LibraryID
|
|
seriesID := m.SeriesID
|
|
seriesName := ""
|
|
seasonID := ""
|
|
if e.mediaShouldBeEpisode(ctx, m) {
|
|
itemType = "Episode"
|
|
seriesID = e.seriesIDForMedia(m)
|
|
seriesName = e.seriesNameForMedia(m)
|
|
seasonID = e.seasonIDForMedia(m)
|
|
parentID = seasonID
|
|
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
|
|
if episodeTitle != "" {
|
|
name = episodeTitle
|
|
} else if m.EpisodeNum > 0 {
|
|
name = fmt.Sprintf("第 %d 集", m.EpisodeNum)
|
|
}
|
|
}
|
|
imageTags := map[string]string{}
|
|
backdropTags := []string{}
|
|
primaryArtwork := e.mediaPrimaryArtwork(ctx, m)
|
|
backdropArtwork := e.mediaBackdropArtwork(ctx, m)
|
|
if primaryArtwork != "" {
|
|
imageTags["Primary"] = m.ID
|
|
}
|
|
if backdropArtwork != "" {
|
|
backdropTags = append(backdropTags, m.ID+"-bd")
|
|
}
|
|
|
|
runTimeTicks := int64(m.DurationSec) * 10_000_000
|
|
durationMs := int64(m.DurationSec) * 1000
|
|
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
|
|
pct := 0.0
|
|
if durationMs > 0 {
|
|
pct = float64(posMs) / float64(durationMs) * 100
|
|
}
|
|
|
|
item := map[string]any{
|
|
"Id": m.ID,
|
|
"Name": name,
|
|
"OriginalTitle": m.OriginalName,
|
|
"ServerId": embyServerID,
|
|
"Type": itemType,
|
|
"MediaType": "Video",
|
|
"IsFolder": false,
|
|
"ProductionYear": m.Year,
|
|
"ParentIndexNumber": m.SeasonNum,
|
|
"IndexNumber": m.EpisodeNum,
|
|
"Overview": m.Overview,
|
|
"RunTimeTicks": runTimeTicks,
|
|
"CommunityRating": m.Rating,
|
|
"Container": m.Container,
|
|
"Width": m.Width,
|
|
"Height": m.Height,
|
|
"DateCreated": m.CreatedAt,
|
|
"Path": m.Path,
|
|
"ParentId": parentID,
|
|
"SeasonId": seasonID,
|
|
"SeasonName": seasonName(m.SeasonNum),
|
|
"SeriesId": seriesID,
|
|
"SeriesName": seriesName,
|
|
"ImageTags": imageTags,
|
|
"BackdropImageTags": backdropTags,
|
|
"Genres": splitCSV(m.Genres),
|
|
"ProviderIds": map[string]string{
|
|
"Tmdb": intToStr(m.TMDbID),
|
|
"Bangumi": intToStr(m.BangumiID),
|
|
},
|
|
"UserData": map[string]any{
|
|
"PlaybackPositionTicks": posMs * 10_000,
|
|
"PlayCount": 0,
|
|
"IsFavorite": fav,
|
|
"Played": played,
|
|
"PlayedPercentage": pct,
|
|
},
|
|
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
|
|
}
|
|
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
|
|
item["PremiereDate"] = premiered
|
|
}
|
|
return item
|
|
}
|