diff --git a/internal/handler/emby_auth.go b/internal/handler/emby_auth.go
index f960355..9ab16a6 100644
--- a/internal/handler/emby_auth.go
+++ b/internal/handler/emby_auth.go
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
return ""
}
+// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
+// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
+// 否则多用户会串到同一个 UserId 上。
+func embyEffectiveUserID(c *gin.Context) string {
+ return embyUserID(c)
+}
+
const embyCompatSessionTTL = 30 * time.Minute
type embyCompatSession struct {
diff --git a/internal/handler/emby_items_handlers.go b/internal/handler/emby_items_handlers.go
index 55d2bda..f2b2f61 100644
--- a/internal/handler/emby_items_handlers.go
+++ b/internal/handler/emby_items_handlers.go
@@ -13,13 +13,7 @@ import (
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
- uid := c.Param("userId")
- if uid == "" {
- uid = firstQueryValue(c, "UserId", "userId", "userid")
- }
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
splitOpt := func(s string) []string {
if s == "" {
return nil
@@ -73,10 +67,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
- uid := c.Param("userId")
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -106,13 +97,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
- uid := c.Param("userId")
- if uid == "" {
- uid = firstQueryValue(c, "UserId", "userId", "userid")
- }
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
if err != nil {
@@ -126,13 +111,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
- uid := c.Param("userId")
- if uid == "" {
- uid = firstQueryValue(c, "UserId", "userId", "userid")
- }
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
if err != nil {
@@ -147,13 +126,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil && svc.Emby != nil {
- uid := firstQueryValue(c, "UserId", "userId")
- if uid == "" {
- uid = c.Param("userId")
- }
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -209,7 +182,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
params := service.ItemsParams{
- UserID: firstQueryValue(c, "UserId", "userId"),
+ UserID: embyEffectiveUserID(c),
ParentID: c.Param("id"),
Limit: 500,
}
@@ -230,7 +203,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
parentID = c.Param("id")
}
params := service.ItemsParams{
- UserID: firstQueryValue(c, "UserId", "userId"),
+ UserID: embyEffectiveUserID(c),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
diff --git a/internal/handler/emby_playback.go b/internal/handler/emby_playback.go
index af26ab2..5102ce2 100644
--- a/internal/handler/emby_playback.go
+++ b/internal/handler/emby_playback.go
@@ -13,10 +13,7 @@ import (
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
- uid := c.Param("userId")
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
}
return
}
- uid := c.Param("userId")
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
ctx := c.Request.Context()
// The official-format route carries a :format suffix (Stream.ass /
// Stream.vtt); prefer it for the Content-Type when present, otherwise
diff --git a/internal/handler/emby_playstate_handlers.go b/internal/handler/emby_playstate_handlers.go
index f3bb1e7..70affd7 100644
--- a/internal/handler/emby_playstate_handlers.go
+++ b/internal/handler/emby_playstate_handlers.go
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
return func(c *gin.Context) {
- uid := c.Param("userId")
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
@@ -116,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
return func(c *gin.Context) {
- uid := c.Param("userId")
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
diff --git a/internal/handler/emby_views_handlers.go b/internal/handler/emby_views_handlers.go
index 093963b..2296b1d 100644
--- a/internal/handler/emby_views_handlers.go
+++ b/internal/handler/emby_views_handlers.go
@@ -10,10 +10,7 @@ import (
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
- uid := c.Param("userId")
- if uid == "" {
- uid = embyUserID(c)
- }
+ uid := embyEffectiveUserID(c)
out, err := svc.Emby.Views(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go
index 78b1f0c..a6204dd 100644
--- a/internal/service/emby_compat.go
+++ b/internal/service/emby_compat.go
@@ -205,6 +205,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if containsEmbyFilter(p.Filters, "IsFavorite") {
return e.favoriteItems(ctx, p)
}
+ // 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
+ if containsEmbyFilter(p.Filters, "IsResumable") {
+ return e.resumableItems(ctx, p)
+ }
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go
index 13e894c..6748dcd 100644
--- a/internal/service/emby_items_detail.go
+++ b/internal/service/emby_items_detail.go
@@ -38,14 +38,6 @@ 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 {
@@ -466,12 +458,26 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
+ mediaIDs := make([]string, 0, len(hist))
localIDs := make([]string, 0, len(hist))
for _, h := range hist {
+ mediaIDs = append(mediaIDs, h.MediaID)
if !IsEmbyRemoteID(h.MediaID) {
localIDs = append(localIDs, h.MediaID)
}
}
+ favSet := map[string]bool{}
+ if len(mediaIDs) > 0 {
+ var favs []model.Favorite
+ if err := e.repo.DB.WithContext(ctx).
+ Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
+ Find(&favs).Error; err != nil {
+ return nil, err
+ }
+ for _, fav := range favs {
+ favSet[fav.MediaID] = true
+ }
+ }
byID := map[string]*model.Media{}
if len(localIDs) > 0 {
var medias []model.Media
@@ -498,7 +504,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
localTotal++
- slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
+ slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, favSet[h.MediaID], h.PositionMs, false)})
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
@@ -559,7 +565,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue
}
}
- item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
+ item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
slots[i].item = item
}
}
diff --git a/internal/service/emby_user_data.go b/internal/service/emby_user_data.go
index 1851945..6c3c981 100644
--- a/internal/service/emby_user_data.go
+++ b/internal/service/emby_user_data.go
@@ -11,8 +11,8 @@ import (
"github.com/truewhile/MeBox/internal/model"
)
-// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
-// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
+// SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
+// 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
return err
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
}
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
-// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
+// 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
- if e.remote != nil && IsEmbyRemoteID(mediaID) {
- acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
- if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil {
- return err
- }
- return nil
- }
if !played {
err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
@@ -40,15 +33,20 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
}
return err
}
- m, err := e.repo.Media.FindByID(ctx, mediaID)
- if err != nil || m == nil {
- return errors.New("media not found")
+ dur := int64(0)
+ if IsEmbyRemoteID(mediaID) {
+ dur = remoteItemDurationMs(ctx, e, mediaID)
+ } else {
+ m, err := e.repo.Media.FindByID(ctx, mediaID)
+ if err != nil || m == nil {
+ return errors.New("media not found")
+ }
+ dur = int64(m.DurationSec) * 1000
}
- dur := int64(m.DurationSec) * 1000
if dur <= 0 {
dur = 1
}
- err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
+ err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: dur,
@@ -62,6 +60,36 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
return err
}
+func remoteItemDurationMs(ctx context.Context, e *EmbyService, mediaID string) int64 {
+ if e == nil || e.remote == nil || !IsEmbyRemoteID(mediaID) {
+ return 0
+ }
+ mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
+ mount, acct, err := e.remote.ResolveMount(ctx, mountID)
+ if err != nil || mount == nil || acct == nil {
+ return 0
+ }
+ item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
+ if err != nil || item == nil {
+ return 0
+ }
+ switch ticks := item["RunTimeTicks"].(type) {
+ case float64:
+ if ticks > 0 {
+ return int64(ticks) / 10_000
+ }
+ case int64:
+ if ticks > 0 {
+ return ticks / 10_000
+ }
+ case int:
+ if ticks > 0 {
+ return int64(ticks) / 10_000
+ }
+ }
+ return 0
+}
+
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
// 不携带 PlaySessionId 的旧调用仍保持兼容。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
@@ -138,8 +166,9 @@ func embyPlaySessionStartedAtMs(playSessionID string) int64 {
return value
}
-// mergeRemoteUserData applies the current MeBox user's locally recorded playback
-// and favourite state to remote Emby payloads.
+// mergeRemoteUserData overlays the current MeBox user's locally recorded
+// playback and favourite state onto remote Emby payloads. Remote mounts share
+// one upstream Emby account, so upstream UserData must never leak across MeBox users.
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
if strings.TrimSpace(userID) == "" || payload == nil {
return nil
@@ -178,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
}
for _, item := range items {
id, _ := item["Id"].(string)
- userData, _ := item["UserData"].(map[string]any)
- if h := byMediaID[id]; h != nil {
- 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
- }
+ item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
}
return nil
}
@@ -221,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
}
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any {
+ favorite := false
+ if existing, ok := raw.(map[string]any); ok {
+ if v, ok := existing["IsFavorite"].(bool); ok {
+ favorite = v
+ }
+ }
+ return applyMeBoxUserData(raw, history, favorite)
+}
+
+// applyMeBoxUserData rebuilds UserData for a remote item using only MeBox-local
+// per-user state. Shared upstream Emby favourite/progress fields are discarded.
+func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool) map[string]any {
userData := map[string]any{}
if existing, ok := raw.(map[string]any); ok {
for key, value := range existing {
- userData[key] = value
+ switch key {
+ case "IsFavorite", "PlaybackPositionTicks", "Played", "PlayedPercentage", "PlayCount", "LastPlayedDate":
+ continue
+ default:
+ userData[key] = value
+ }
}
}
+ userData["IsFavorite"] = favorite
+ if history == nil {
+ userData["PlaybackPositionTicks"] = int64(0)
+ userData["Played"] = false
+ userData["PlayedPercentage"] = float64(0)
+ userData["PlayCount"] = 0
+ return userData
+ }
duration := history.DurationMs
position := history.PositionMs
percentage := float64(0)
@@ -237,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
userData["Played"] = history.Completed
userData["PlayedPercentage"] = percentage
if history.Completed {
- playCount := 0
- switch value := userData["PlayCount"].(type) {
- case int:
- playCount = value
- case int64:
- playCount = int(value)
- case float64:
- playCount = int(value)
- }
- if playCount < 1 {
- userData["PlayCount"] = 1
- }
+ userData["PlayCount"] = 1
+ } else {
+ userData["PlayCount"] = 0
}
return userData
}
diff --git a/internal/service/emby_user_data_test.go b/internal/service/emby_user_data_test.go
index 372026b..2ec1486 100644
--- a/internal/service/emby_user_data_test.go
+++ b/internal/service/emby_user_data_test.go
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
preserve any
}{
{
- name: "in-progress preserves remote fields",
+ name: "in-progress preserves non-user remote fields only",
raw: map[string]any{
- "PlayCount": 2,
- "Custom": "remote-value",
+ "PlayCount": 2,
+ "IsFavorite": true,
+ "PlaybackPositionTicks": int64(999),
+ "Custom": "remote-value",
},
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
position: 250_000_000,
played: false,
percent: 25,
- count: 2,
+ count: 0,
preserve: "remote-value",
},
{
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
if tt.preserve != nil && out["Custom"] != tt.preserve {
t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve)
}
+ if out["IsFavorite"] != false && out["IsFavorite"] != true {
+ t.Fatalf("IsFavorite missing: %#v", out)
+ }
})
}
}
+func TestApplyMeBoxUserDataClearsSharedRemoteState(t *testing.T) {
+ out := applyMeBoxUserData(map[string]any{
+ "IsFavorite": true,
+ "PlaybackPositionTicks": int64(42_000_000),
+ "Played": true,
+ "PlayedPercentage": 80.0,
+ "PlayCount": 3,
+ "Key": "keep",
+ }, nil, false)
+ if out["IsFavorite"] != false {
+ t.Fatalf("IsFavorite = %#v, want false", out["IsFavorite"])
+ }
+ if out["PlaybackPositionTicks"] != int64(0) {
+ t.Fatalf("PlaybackPositionTicks = %#v, want 0", out["PlaybackPositionTicks"])
+ }
+ if out["Played"] != false {
+ t.Fatalf("Played = %#v, want false", out["Played"])
+ }
+ if out["PlayedPercentage"] != float64(0) {
+ t.Fatalf("PlayedPercentage = %#v, want 0", out["PlayedPercentage"])
+ }
+ if out["PlayCount"] != 0 {
+ t.Fatalf("PlayCount = %#v, want 0", out["PlayCount"])
+ }
+ if out["Key"] != "keep" {
+ t.Fatalf("Key = %#v, want keep", out["Key"])
+ }
+
+ fav := applyMeBoxUserData(map[string]any{"IsFavorite": false}, nil, true)
+ if fav["IsFavorite"] != true {
+ t.Fatalf("favorite overlay IsFavorite = %#v, want true", fav["IsFavorite"])
+ }
+}
+
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
payload := map[string]any{
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
t.Fatalf("expected 95%% progress to be completed")
}
}
+
+func TestMarkPlayedStoresRemoteItemLocallyPerUser(t *testing.T) {
+ svc := newTestEmbyService(t)
+ remoteID := EncodeEmbyRemoteID("mount-test", "item-played")
+ alice := &model.User{Username: "alice_played", Role: "user", Tier: "free", IsActive: true}
+ bob := &model.User{Username: "bob_played", Role: "user", Tier: "free", IsActive: true}
+ if err := svc.repo.User.Create(t.Context(), alice); err != nil {
+ t.Fatalf("create alice: %v", err)
+ }
+ if err := svc.repo.User.Create(t.Context(), bob); err != nil {
+ t.Fatalf("create bob: %v", err)
+ }
+
+ if err := svc.MarkPlayed(t.Context(), alice.ID, remoteID, true); err != nil {
+ t.Fatalf("mark played: %v", err)
+ }
+
+ var aliceRows, bobRows int64
+ _ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", alice.ID, remoteID).Count(&aliceRows)
+ _ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", bob.ID, remoteID).Count(&bobRows)
+ if aliceRows != 1 {
+ t.Fatalf("alice history rows = %d, want 1", aliceRows)
+ }
+ if bobRows != 0 {
+ t.Fatalf("bob should not see alice remote played state, rows=%d", bobRows)
+ }
+
+ payload := map[string]any{
+ "Id": remoteID,
+ "UserData": map[string]any{
+ "IsFavorite": true,
+ "PlaybackPositionTicks": int64(50_000_000),
+ "Played": true,
+ },
+ }
+ if err := svc.mergeRemoteUserData(t.Context(), bob.ID, payload); err != nil {
+ t.Fatalf("merge for bob: %v", err)
+ }
+ bobData := payload["UserData"].(map[string]any)
+ if bobData["IsFavorite"] != false {
+ t.Fatalf("bob IsFavorite leaked: %#v", bobData)
+ }
+ if bobData["Played"] != false || bobData["PlaybackPositionTicks"] != int64(0) {
+ t.Fatalf("bob playback leaked: %#v", bobData)
+ }
+
+ if err := svc.mergeRemoteUserData(t.Context(), alice.ID, payload); err != nil {
+ t.Fatalf("merge for alice: %v", err)
+ }
+ aliceData := payload["UserData"].(map[string]any)
+ if aliceData["Played"] != true {
+ t.Fatalf("alice Played = %#v, want true", aliceData["Played"])
+ }
+}
diff --git a/internal/service/favorites_sync.go b/internal/service/favorites_sync.go
index eb6c069..cc72d5d 100644
--- a/internal/service/favorites_sync.go
+++ b/internal/service/favorites_sync.go
@@ -10,21 +10,14 @@ import (
"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 {
+// SyncUserFavorite keeps favourite state in the local favourites table, keyed
+// by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
+// state is intentionally not proxied upstream.
+func SyncUserFavorite(ctx context.Context, repo *repository.Container, _ *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
+ return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
}
// IsUserFavorite reports whether the user has favourited mediaID locally.
@@ -57,18 +50,3 @@ func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, m
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/web/src/components/GlobalEvents.tsx b/web/src/components/GlobalEvents.tsx
index 9bc5ff5..bdc359f 100644
--- a/web/src/components/GlobalEvents.tsx
+++ b/web/src/components/GlobalEvents.tsx
@@ -37,6 +37,7 @@ export function GlobalEvents() {
}
}
if (topic === 'scrape' && p.finished) {
+ if (role !== 'admin') return
const processed = Number(p.processed ?? 0)
const matched = Number(p.matched ?? 0)
const failed = Number(p.failed ?? 0)
@@ -51,6 +52,7 @@ export function GlobalEvents() {
}
}
if (topic === 'subscription') {
+ if (role !== 'admin') return
const queued = (p.queued as number | undefined) ?? 0
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
}
diff --git a/web/src/components/useLayoutPermissions.ts b/web/src/components/useLayoutPermissions.ts
index 3ed4c0f..27228a7 100644
--- a/web/src/components/useLayoutPermissions.ts
+++ b/web/src/components/useLayoutPermissions.ts
@@ -5,7 +5,6 @@ import type { User } from '../types'
export function useLayoutPermissions(user: User | null | undefined) {
const permissions = usePermissionStore((state) => state.permissions)
- const isSuper = usePermissionStore((state) => state.isSuper)
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
@@ -17,8 +16,8 @@ export function useLayoutPermissions(user: User | null | undefined) {
const isAdmin = user?.role === 'admin'
const can = useCallback(
- (key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
- [isAdmin, isSuper, permissions],
+ (key: string) => isAdmin || (permissions ?? {})[key] === true,
+ [isAdmin, permissions],
)
return { can, isAdmin }
diff --git a/web/src/hooks/usePermission.ts b/web/src/hooks/usePermission.ts
index 687cc68..4e5c25d 100644
--- a/web/src/hooks/usePermission.ts
+++ b/web/src/hooks/usePermission.ts
@@ -5,17 +5,17 @@ import { useAuthStore } from '../stores/auth'
/**
* usePermission hook - 检查用户是否拥有特定权限
- *
+ *
* @param key - 权限键名
* @param options - 配置选项
* @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
* @returns boolean - 用户是否拥有该权限
- *
+ *
* @example
* ```tsx
* function MyComponent() {
* const canEdit = usePermission('can_edit_media')
- *
+ *
* if (canEdit) {
* return
暂无媒体库,请到管理后台添加目录。
++ {isAdmin ? '暂无媒体库,请到管理后台添加目录。' : '暂无可用媒体库。'} +