diff --git a/internal/handler/media_favorite.go b/internal/handler/media_favorite.go index 70e3448..f5c8bfc 100644 --- a/internal/handler/media_favorite.go +++ b/internal/handler/media_favorite.go @@ -13,7 +13,6 @@ import ( "github.com/gin-gonic/gin" "github.com/truewhile/MeBox/internal/middleware" - "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/service" ) @@ -23,18 +22,18 @@ import ( func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) - // Check current state. - var existing model.Favorite - err := svc.Repo.DB.WithContext(c.Request.Context()). - Where("user_id = ? AND media_id = ?", uid, c.Param("id")). - First(&existing).Error - if err == nil { + userID := toString(uid) + mediaID := c.Param("id") + favorite, err := service.IsUserFavorite(c.Request.Context(), svc.Repo, userID, mediaID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if favorite { c.JSON(http.StatusOK, gin.H{"favourite": true}) return } - // Otherwise create. - fav := &model.Favorite{UserID: toString(uid), MediaID: c.Param("id")} - if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(fav).Error; err != nil { + if err := svc.Playback.SetFavourite(c.Request.Context(), userID, mediaID, true); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -46,9 +45,7 @@ func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc { func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) - if err := svc.Repo.DB.WithContext(c.Request.Context()). - Where("user_id = ? AND media_id = ?", uid, c.Param("id")). - Delete(&model.Favorite{}).Error; err != nil { + if err := svc.Playback.SetFavourite(c.Request.Context(), toString(uid), c.Param("id"), false); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -60,12 +57,12 @@ func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc { func getMediaFavoriteStatusHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) - var n int64 - _ = svc.Repo.DB.WithContext(c.Request.Context()). - Model(&model.Favorite{}). - Where("user_id = ? AND media_id = ?", uid, c.Param("id")). - Count(&n).Error - c.JSON(http.StatusOK, gin.H{"favourite": n > 0}) + favorite, err := service.IsUserFavorite(c.Request.Context(), svc.Repo, toString(uid), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"favourite": favorite}) } } diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index c557a69..5d931d2 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -148,6 +148,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, if e.remote != nil { // 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。 if IsEmbyRemoteID(p.ParentID) { + if containsEmbyFilter(p.Filters, "IsFavorite") { + return e.favoriteItems(ctx, p) + } mountID, _, _ := DecodeEmbyRemoteID(p.ParentID) mount, acct, _ := e.remote.ResolveMount(ctx, mountID) if mount == nil || acct == nil { @@ -171,6 +174,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, if containsEmbyFilter(p.Filters, "IsResumable") { return e.resumableItems(ctx, p) } + if containsEmbyFilter(p.Filters, "IsFavorite") { + return e.favoriteItems(ctx, p) + } if len(p.IDs) > 0 { items := make([]map[string]any, 0, len(p.IDs)) diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 53df106..4be9bbf 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -28,6 +28,14 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str 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 { @@ -180,6 +188,137 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) 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 { diff --git a/internal/service/emby_user_data.go b/internal/service/emby_user_data.go index 395f3d1..eba8026 100644 --- a/internal/service/emby_user_data.go +++ b/internal/service/emby_user_data.go @@ -7,35 +7,17 @@ import ( "strings" "time" - "gorm.io/gorm" - "github.com/truewhile/MeBox/internal/model" ) -// SetFavorite 把 mediaID 标为 userID 的收藏。远程 Emby 条目直接透传到对应 -// 服务器(本地不落库)。 +// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入 +// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。 func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error { - if e.remote != nil && IsEmbyRemoteID(mediaID) { - acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID) - if err := e.ProxyRemoteSetFavorite(ctx, acctID, remoteID, favorite); err != nil { - return err - } - return nil - } - if favorite { - var f model.Favorite - err := e.repo.DB.WithContext(ctx). - Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return e.repo.DB.WithContext(ctx).Create(&model.Favorite{ - UserID: userID, MediaID: mediaID, - }).Error - } + if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil { return err } - return e.repo.DB.WithContext(ctx). - Where("user_id = ? AND media_id = ?", userID, mediaID). - Delete(&model.Favorite{}).Error + e.invalidateEmbyItemsCache(ctx) + return nil } // MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。 @@ -122,8 +104,7 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string } // mergeRemoteUserData applies the current MeBox user's locally recorded playback -// state to remote Emby payloads. Remote metadata remains authoritative unless the -// user has played the item through MeBox. +// and favourite state to remote Emby payloads. func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error { if strings.TrimSpace(userID) == "" || payload == nil { return nil @@ -152,10 +133,27 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa for i := range histories { byMediaID[histories[i].MediaID] = &histories[i] } + var favs []model.Favorite + if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id IN ?", userID, ids).Find(&favs).Error; err != nil { + return err + } + favSet := make(map[string]bool, len(favs)) + for _, fav := range favs { + favSet[fav.MediaID] = true + } for _, item := range items { id, _ := item["Id"].(string) + userData, _ := item["UserData"].(map[string]any) if h := byMediaID[id]; h != nil { - item["UserData"] = mergedRemoteUserData(item["UserData"], h) + item["UserData"] = mergedRemoteUserData(userData, h) + userData, _ = item["UserData"].(map[string]any) + } + if favSet[id] { + if userData == nil { + userData = map[string]any{} + item["UserData"] = userData + } + userData["IsFavorite"] = true } } return nil diff --git a/internal/service/favorites_sync.go b/internal/service/favorites_sync.go new file mode 100644 index 0000000..eb6c069 --- /dev/null +++ b/internal/service/favorites_sync.go @@ -0,0 +1,74 @@ +package service + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +// SyncUserFavorite keeps favourite state aligned across the local favourites table +// and the upstream remote Emby server for mounted items. +func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error { + if repo == nil || userID == "" || mediaID == "" { + return errors.New("missing favourite sync inputs") + } + if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil { + return err + } + if favorite || IsEmbyRemoteID(mediaID) { + if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil { + return err + } + } + return nil +} + +// IsUserFavorite reports whether the user has favourited mediaID locally. +func IsUserFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string) (bool, error) { + if repo == nil || userID == "" || mediaID == "" { + return false, nil + } + var count int64 + err := repo.DB.WithContext(ctx).Model(&model.Favorite{}). + Where("user_id = ? AND media_id = ?", userID, mediaID). + Count(&count).Error + return count > 0, err +} + +func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string, favorite bool) error { + if favorite { + var existing model.Favorite + err := repo.DB.WithContext(ctx). + Where("user_id = ? AND media_id = ?", userID, mediaID). + First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return repo.DB.WithContext(ctx).Create(&model.Favorite{ + UserID: userID, + MediaID: mediaID, + }).Error + } + return err + } + return repo.DB.WithContext(ctx). + Where("user_id = ? AND media_id = ?", userID, mediaID). + Delete(&model.Favorite{}).Error +} + +func proxyRemoteFavorite(ctx context.Context, remote *EmbyRemoteService, mediaID string, favorite bool) error { + if remote == nil || !IsEmbyRemoteID(mediaID) { + return nil + } + mountID, remoteItemID, ok := DecodeEmbyRemoteID(mediaID) + if !ok { + return nil + } + _, acct, err := remote.ResolveMount(ctx, mountID) + if err != nil { + return err + } + return remote.ProxySetFavorite(ctx, acct, remoteItemID, favorite) +} diff --git a/internal/service/favorites_sync_test.go b/internal/service/favorites_sync_test.go new file mode 100644 index 0000000..0c7e85c --- /dev/null +++ b/internal/service/favorites_sync_test.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +func TestSyncUserFavoriteWritesLocalForRemoteID(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Favorite{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + userID := "user-1" + remoteMediaID := EncodeEmbyRemoteID("mount-1", "remote-item-1") + + if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, true); err != nil { + t.Fatalf("SyncUserFavorite favorite: %v", err) + } + favorite, err := IsUserFavorite(context.Background(), repos, userID, remoteMediaID) + if err != nil { + t.Fatalf("IsUserFavorite: %v", err) + } + if !favorite { + t.Fatal("expected remote favourite to be stored locally") + } + + if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, false); err != nil { + t.Fatalf("SyncUserFavorite unfavorite: %v", err) + } + favorite, err = IsUserFavorite(context.Background(), repos, userID, remoteMediaID) + if err != nil { + t.Fatalf("IsUserFavorite after delete: %v", err) + } + if favorite { + t.Fatal("expected remote favourite to be removed locally") + } +} + +func TestFavoriteItemsIncludesRemoteFavourites(t *testing.T) { + db := newServiceTestDB(t, &model.User{}, &model.Library{}, &model.Media{}, &model.Favorite{}) + repos := repository.New(db) + viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"} + if err := repos.User.Create(t.Context(), viewer); err != nil { + t.Fatal(err) + } + remoteMediaID := EncodeEmbyRemoteID("mount-1", "remote-item-1") + if err := db.Create(&model.Favorite{UserID: viewer.ID, MediaID: remoteMediaID}).Error; err != nil { + t.Fatal(err) + } + + svc := &EmbyService{repo: repos} + out, err := svc.favoriteItems(t.Context(), ItemsParams{UserID: viewer.ID, Limit: 50}) + if err != nil { + t.Fatalf("favoriteItems: %v", err) + } + total, _ := out["TotalRecordCount"].(int64) + if total != 0 { + // Without a wired remote service hydration is skipped, but local-only path + // should not error and should not count unavailable remote rows. + items, _ := out["Items"].([]map[string]any) + if len(items) != 0 { + t.Fatalf("expected no hydrated remote rows without remote service, got %#v", out) + } + } +} diff --git a/internal/service/playback.go b/internal/service/playback.go index db31a04..cede9ba 100644 --- a/internal/service/playback.go +++ b/internal/service/playback.go @@ -159,7 +159,20 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi // ToggleFavourite flips the favourite flag and reports the new state. func (p *PlaybackService) ToggleFavourite(ctx context.Context, userID, mediaID string) (bool, error) { - return p.repo.Favorite.Toggle(ctx, userID, mediaID) + current, err := IsUserFavorite(ctx, p.repo, userID, mediaID) + if err != nil { + return false, err + } + next := !current + if err := p.SetFavourite(ctx, userID, mediaID, next); err != nil { + return false, err + } + return next, nil +} + +// SetFavourite sets favourite state for a media item. +func (p *PlaybackService) SetFavourite(ctx context.Context, userID, mediaID string, favorite bool) error { + return SyncUserFavorite(ctx, p.repo, p.remote, userID, mediaID, favorite) } // ListFavourites returns every favourited media for a user.