Compare commits

...

7 Commits

Author SHA1 Message Date
truewhile ff86c2f597 yb显示 2026-09-15 18:50:50 +08:00
truewhile a5731fa963 优化显示 2026-09-15 18:26:20 +08:00
truewhile 531e45f677 优化 2026-09-15 16:48:42 +08:00
truewhile cd3f028f0b 修复bug 2026-09-15 16:12:59 +08:00
truewhile bd7b7a722b Merge pull request #34 from truewhile/cursor/hide-viewer-admin-ui
Cursor/hide viewer admin UI
2026-09-15 15:23:58 +08:00
truewhile 83deffcf45 修复 2026-09-15 15:21:43 +08:00
truewhile c74f6be250 Hide scrape and library admin controls from non-admin users.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 15:20:09 +08:00
22 changed files with 454 additions and 263 deletions
+2
View File
@@ -0,0 +1,2 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+7
View File
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
return "" return ""
} }
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
// 否则多用户会串到同一个 UserId 上。
func embyEffectiveUserID(c *gin.Context) string {
return embyUserID(c)
}
const embyCompatSessionTTL = 30 * time.Minute const embyCompatSessionTTL = 30 * time.Minute
type embyCompatSession struct { type embyCompatSession struct {
+7 -34
View File
@@ -13,13 +13,7 @@ import (
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50")) limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0")) offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
splitOpt := func(s string) []string { splitOpt := func(s string) []string {
if s == "" { if s == "" {
return nil return nil
@@ -73,10 +67,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc { func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.Item(c.Request.Context(), id, uid) out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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 { func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20")) limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit) out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
if err != nil { if err != nil {
@@ -126,13 +111,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc { func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20")) limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit) out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
if err != nil { if err != nil {
@@ -147,13 +126,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc { func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if svc != nil && svc.Emby != nil { if svc != nil && svc.Emby != nil {
uid := firstQueryValue(c, "UserId", "userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = c.Param("userId")
}
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid) out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 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 { func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
params := service.ItemsParams{ params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"), UserID: embyEffectiveUserID(c),
ParentID: c.Param("id"), ParentID: c.Param("id"),
Limit: 500, Limit: 500,
} }
@@ -230,7 +203,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
parentID = c.Param("id") parentID = c.Param("id")
} }
params := service.ItemsParams{ params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"), UserID: embyEffectiveUserID(c),
ParentID: parentID, ParentID: parentID,
IncludeItemTypes: []string{"Episode"}, IncludeItemTypes: []string{"Episode"},
Recursive: true, Recursive: true,
+2 -8
View File
@@ -13,10 +13,7 @@ import (
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc { func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid) out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
} }
return return
} }
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = embyUserID(c)
}
ctx := c.Request.Context() ctx := c.Request.Context()
// The official-format route carries a :format suffix (Stream.ass / // The official-format route carries a :format suffix (Stream.ass /
// Stream.vtt); prefer it for the Content-Type when present, otherwise // Stream.vtt); prefer it for the Content-Type when present, otherwise
+2 -8
View File
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc { func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = embyUserID(c)
}
mid := c.Param("itemId") mid := c.Param("itemId")
if uid == "" || mid == "" { if uid == "" || mid == "" {
c.Status(http.StatusBadRequest) 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 { func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = embyUserID(c)
}
mid := c.Param("itemId") mid := c.Param("itemId")
if uid == "" || mid == "" { if uid == "" || mid == "" {
c.Status(http.StatusBadRequest) c.Status(http.StatusBadRequest)
+1 -4
View File
@@ -10,10 +10,7 @@ import (
func embyViewsHandler(svc *service.Container) gin.HandlerFunc { func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid := c.Param("userId") uid := embyEffectiveUserID(c)
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.Views(c.Request.Context(), uid) out, err := svc.Emby.Views(c.Request.Context(), uid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+32 -40
View File
@@ -92,13 +92,14 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
func historyContinueHandler(svc *service.Container) gin.HandlerFunc { func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID) uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 { if limit <= 0 || limit > 50 {
limit = 10 limit = 10
} }
var rows []model.PlaybackHistory var rows []model.PlaybackHistory
if err := svc.Repo.DB. if err := svc.Repo.DB.
Where("user_id = ? AND completed = ?", toString(uid), false). Where("user_id = ? AND completed = ?", userID, false).
Order("watched_at desc"). Order("watched_at desc").
Limit(limit). Limit(limit).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
@@ -116,61 +117,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
} }
mIdx := make(map[string]model.Media, len(media)) mIdx := make(map[string]model.Media, len(media))
for _, m := range media { for _, m := range media {
if !mediaVisibleForRequest(c, svc, &m) {
continue
}
mIdx[m.ID] = m mIdx[m.ID] = m
} }
out := make([]gin.H, 0, len(rows)) out := make([]gin.H, 0, len(rows))
staleIDs := make([]string, 0)
for _, r := range rows { for _, r := range rows {
m, ok := mIdx[r.MediaID] m, ok := mIdx[r.MediaID]
if !ok { if ok {
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) { if mediaVisibleForRequest(c, svc, &m) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
if mediaVisibleForRequest(c, svc, rm) {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
}
continue
}
}
}
fallback := fallbackHistoryMedia(r.MediaID)
if fallback != nil {
out = append(out, gin.H{ out = append(out, gin.H{
"history": r, "history": r,
"media": *fallback, "media": m,
}) })
} }
continue continue
} }
out = append(out, gin.H{
"history": r, if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
"media": m, mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
}) mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID)
if resolveErr == nil && mount != nil && acct != nil {
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID)
if detailErr == nil && remoteMedia != nil {
if mediaVisibleForRequest(c, svc, remoteMedia) {
out = append(out, gin.H{
"history": r,
"media": *remoteMedia,
})
}
continue
}
}
}
// 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404,
// 因此清理这条失效播放记录,不再占用继续观看列表。
staleIDs = append(staleIDs, r.MediaID)
}
if len(staleIDs) > 0 {
_ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped().
Where("user_id = ? AND media_id IN ?", userID, staleIDs).
Delete(&model.PlaybackHistory{}).Error
} }
c.JSON(http.StatusOK, out) c.JSON(http.StatusOK, out)
} }
} }
func fallbackHistoryMedia(mediaID string) *model.Media {
if mediaID == "" {
return nil
}
title := "媒体"
if service.IsEmbyRemoteID(mediaID) {
title = "远程媒体"
}
return &model.Media{
Base: model.Base{ID: mediaID},
Title: title,
}
}
// historyDeleteHandler removes one or all history rows for the caller. // historyDeleteHandler removes one or all history rows for the caller.
// //
// DELETE /api/watch-history?media_id=xxx → delete just that media's row // DELETE /api/watch-history?media_id=xxx → delete just that media's row
+105
View File
@@ -0,0 +1,105 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
valid := model.Media{
Base: model.Base{ID: "media-valid"},
Title: "有效影片",
Path: "/media/valid.mkv",
}
if err := db.Create(&valid).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-valid"},
UserID: "user-1",
MediaID: valid.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: now.Add(-time.Minute),
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-stale"},
UserID: "user-1",
MediaID: "media-deleted",
PositionMs: 60_000,
DurationMs: 120_000,
WatchedAt: now,
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history/continue", historyContinueHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var body []struct {
History model.PlaybackHistory `json:"history"`
Media model.Media `json:"media"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body) != 1 {
t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String())
}
if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" {
t.Fatalf("unexpected row: %#v", body[0])
}
var staleCount int64
if err := db.Unscoped().Model(&model.PlaybackHistory{}).
Where("user_id = ? AND media_id = ?", "user-1", "media-deleted").
Count(&staleCount).Error; err != nil {
t.Fatal(err)
}
if staleCount != 0 {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
+4
View File
@@ -205,6 +205,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
if containsEmbyFilter(p.Filters, "IsFavorite") { if containsEmbyFilter(p.Filters, "IsFavorite") {
return e.favoriteItems(ctx, p) return e.favoriteItems(ctx, p)
} }
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID) mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID) mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil { if mount == nil || acct == nil {
+16 -10
View File
@@ -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 { if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err 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 return out, nil
} }
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != 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 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)) localIDs := make([]string, 0, len(hist))
for _, h := range hist { for _, h := range hist {
mediaIDs = append(mediaIDs, h.MediaID)
if !IsEmbyRemoteID(h.MediaID) { if !IsEmbyRemoteID(h.MediaID) {
localIDs = append(localIDs, 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{} byID := map[string]*model.Media{}
if len(localIDs) > 0 { if len(localIDs) > 0 {
var medias []model.Media var medias []model.Media
@@ -498,7 +504,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue continue
} }
localTotal++ 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 continue
} }
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) { if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
@@ -559,7 +565,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
continue continue
} }
} }
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist) item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
slots[i].item = item slots[i].item = item
} }
} }
+76 -42
View File
@@ -11,8 +11,8 @@ import (
"github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/model"
) )
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入 // SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。 // 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error { 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 { if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
return err return err
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
} }
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。 // MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。 // 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error { 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 { if !played {
err := e.repo.DB.WithContext(ctx). err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID). 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 return err
} }
m, err := e.repo.Media.FindByID(ctx, mediaID) dur := int64(0)
if err != nil || m == nil { if IsEmbyRemoteID(mediaID) {
return errors.New("media not found") 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 { if dur <= 0 {
dur = 1 dur = 1
} }
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{ err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID, UserID: userID,
MediaID: mediaID, MediaID: mediaID,
PositionMs: dur, PositionMs: dur,
@@ -62,6 +60,36 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
return err 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)。 // RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
// 不携带 PlaySessionId 的旧调用仍保持兼容。 // 不携带 PlaySessionId 的旧调用仍保持兼容。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error { 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 return value
} }
// mergeRemoteUserData applies the current MeBox user's locally recorded playback // mergeRemoteUserData overlays the current MeBox user's locally recorded
// and favourite state to remote Emby payloads. // 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 { func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
if strings.TrimSpace(userID) == "" || payload == nil { if strings.TrimSpace(userID) == "" || payload == nil {
return nil return nil
@@ -178,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
} }
for _, item := range items { for _, item := range items {
id, _ := item["Id"].(string) id, _ := item["Id"].(string)
userData, _ := item["UserData"].(map[string]any) item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
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
}
} }
return nil return nil
} }
@@ -221,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
} }
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) 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{} userData := map[string]any{}
if existing, ok := raw.(map[string]any); ok { if existing, ok := raw.(map[string]any); ok {
for key, value := range existing { 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 duration := history.DurationMs
position := history.PositionMs position := history.PositionMs
percentage := float64(0) percentage := float64(0)
@@ -237,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
userData["Played"] = history.Completed userData["Played"] = history.Completed
userData["PlayedPercentage"] = percentage userData["PlayedPercentage"] = percentage
if history.Completed { if history.Completed {
playCount := 0 userData["PlayCount"] = 1
switch value := userData["PlayCount"].(type) { } else {
case int: userData["PlayCount"] = 0
playCount = value
case int64:
playCount = int(value)
case float64:
playCount = int(value)
}
if playCount < 1 {
userData["PlayCount"] = 1
}
} }
return userData return userData
} }
+97 -4
View File
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
preserve any preserve any
}{ }{
{ {
name: "in-progress preserves remote fields", name: "in-progress preserves non-user remote fields only",
raw: map[string]any{ raw: map[string]any{
"PlayCount": 2, "PlayCount": 2,
"Custom": "remote-value", "IsFavorite": true,
"PlaybackPositionTicks": int64(999),
"Custom": "remote-value",
}, },
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000}, history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
position: 250_000_000, position: 250_000_000,
played: false, played: false,
percent: 25, percent: 25,
count: 2, count: 0,
preserve: "remote-value", preserve: "remote-value",
}, },
{ {
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
if tt.preserve != nil && out["Custom"] != tt.preserve { if tt.preserve != nil && out["Custom"] != tt.preserve {
t.Fatalf("Custom = %#v, want %#v", 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) { func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
remoteID := EncodeEmbyRemoteID("mount-1", "item-1") remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
payload := map[string]any{ payload := map[string]any{
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
t.Fatalf("expected 95%% progress to be completed") 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"])
}
}
+5 -27
View File
@@ -10,21 +10,14 @@ import (
"github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/repository"
) )
// SyncUserFavorite keeps favourite state aligned across the local favourites table // SyncUserFavorite keeps favourite state in the local favourites table, keyed
// and the upstream remote Emby server for mounted items. // by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error { // 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 == "" { if repo == nil || userID == "" || mediaID == "" {
return errors.New("missing favourite sync inputs") return errors.New("missing favourite sync inputs")
} }
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil { return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
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. // 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). Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.Favorite{}).Error 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)
}
+2
View File
@@ -37,6 +37,7 @@ export function GlobalEvents() {
} }
} }
if (topic === 'scrape' && p.finished) { if (topic === 'scrape' && p.finished) {
if (role !== 'admin') return
const processed = Number(p.processed ?? 0) const processed = Number(p.processed ?? 0)
const matched = Number(p.matched ?? 0) const matched = Number(p.matched ?? 0)
const failed = Number(p.failed ?? 0) const failed = Number(p.failed ?? 0)
@@ -51,6 +52,7 @@ export function GlobalEvents() {
} }
} }
if (topic === 'subscription') { if (topic === 'subscription') {
if (role !== 'admin') return
const queued = (p.queued as number | undefined) ?? 0 const queued = (p.queued as number | undefined) ?? 0
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`) if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
} }
+13 -17
View File
@@ -4,6 +4,9 @@ import { Film, Play, Layers, Star } from 'lucide-react'
import { imageURL } from '../api/client' import { imageURL } from '../api/client'
import type { Media } from '../types' import type { Media } from '../types'
const ACTION_OVERLAY_CLASS =
'absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-100 transition-opacity sm:pointer-events-none sm:opacity-0 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 sm:focus-within:pointer-events-auto sm:focus-within:opacity-100'
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。 // memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。 // 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
export const MediaCard = memo(function MediaCard({ export const MediaCard = memo(function MediaCard({
@@ -79,25 +82,18 @@ export const MediaCard = memo(function MediaCard({
</div> </div>
)} )}
{/* Episode count badge */} {/* Episode count badge:统一放右下角(桌面端与移动端一致),与左上角评分彻底错开 */}
{count !== undefined && count > 1 && ( {(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm"> <span className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm">
<Layers size={10} className="text-[#c9954a]" /> <Layers size={10} className="shrink-0 text-[#c9954a]" />
<span>{count} 集</span> <span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
</span> </span>
)} ) : null}
{count === undefined && versionCount > 1 && (
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
<Layers size={10} className="text-[#c9954a]" />
<span>{versionCount} 版本</span>
</span>
)}
{/* Rating Badge */} {/* Rating Badge */}
{displayRating > 0 && ( {displayRating > 0 && (
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm"> <span className="absolute left-2 top-2 inline-flex items-center gap-0.5 rounded-lg border border-white/15 bg-[#111827]/90 px-1.5 py-0.5 text-[10px] font-bold text-[#c9954a] shadow-sm">
<Star size={10} fill="currentColor" /> <Star size={10} fill="currentColor" className="shrink-0" />
<span>{displayRating.toFixed(1)}</span> <span>{displayRating.toFixed(1)}</span>
</span> </span>
)} )}
@@ -160,7 +156,7 @@ export const MediaCard = memo(function MediaCard({
{card} {card}
</button> </button>
{actionContent && ( {actionContent && (
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"> <div className={ACTION_OVERLAY_CLASS}>
{actionContent} {actionContent}
</div> </div>
)} )}
@@ -174,7 +170,7 @@ export const MediaCard = memo(function MediaCard({
<Link to={href} className="block"> <Link to={href} className="block">
{card} {card}
</Link> </Link>
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"> <div className={ACTION_OVERLAY_CLASS}>
{actionContent} {actionContent}
</div> </div>
</div> </div>
+2 -3
View File
@@ -5,7 +5,6 @@ import type { User } from '../types'
export function useLayoutPermissions(user: User | null | undefined) { export function useLayoutPermissions(user: User | null | undefined) {
const permissions = usePermissionStore((state) => state.permissions) const permissions = usePermissionStore((state) => state.permissions)
const isSuper = usePermissionStore((state) => state.isSuper)
const isPermissionLoading = usePermissionStore((state) => state.isLoading) const isPermissionLoading = usePermissionStore((state) => state.isLoading)
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions) const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
@@ -17,8 +16,8 @@ export function useLayoutPermissions(user: User | null | undefined) {
const isAdmin = user?.role === 'admin' const isAdmin = user?.role === 'admin'
const can = useCallback( const can = useCallback(
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true, (key: string) => isAdmin || (permissions ?? {})[key] === true,
[isAdmin, isSuper, permissions], [isAdmin, permissions],
) )
return { can, isAdmin } return { can, isAdmin }
+8 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin' import { adminAPI } from '../api/admin'
import { useAuthStore } from '../stores/auth'
const SETTING_KEY = 'scrape.episode_images' const SETTING_KEY = 'scrape.episode_images'
@@ -13,8 +14,11 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
const enabledRef = useRef(false) const enabledRef = useRef(false)
const changedLocally = useRef(false) const changedLocally = useRef(false)
const saveQueue = useRef(Promise.resolve()) const saveQueue = useRef(Promise.resolve())
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
useEffect(() => { useEffect(() => {
if (!isAdmin) return
let active = true let active = true
adminAPI.listSettings() adminAPI.listSettings()
.then((settings) => { .then((settings) => {
@@ -32,9 +36,11 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
return () => { return () => {
active = false active = false
} }
}, []) }, [isAdmin])
const setEnabled = useCallback((nextValue: SetStateAction<boolean>) => { const setEnabled = useCallback((nextValue: SetStateAction<boolean>) => {
if (!isAdmin) return
const next = typeof nextValue === 'function' ? nextValue(enabledRef.current) : nextValue const next = typeof nextValue === 'function' ? nextValue(enabledRef.current) : nextValue
if (enabledRef.current === next) return if (enabledRef.current === next) return
changedLocally.current = true changedLocally.current = true
@@ -47,7 +53,7 @@ export function useEpisodeArtworkPreference(): [boolean, Dispatch<SetStateAction
.catch(() => { .catch(() => {
toast.error('保存每集图片设置失败') toast.error('保存每集图片设置失败')
}) })
}, []) }, [isAdmin])
return [enabled, setEnabled] return [enabled, setEnabled]
} }
+20 -22
View File
@@ -5,17 +5,17 @@ import { useAuthStore } from '../stores/auth'
/** /**
* usePermission hook - 检查用户是否拥有特定权限 * usePermission hook - 检查用户是否拥有特定权限
* *
* @param key - 权限键名 * @param key - 权限键名
* @param options - 配置选项 * @param options - 配置选项
* @param options.autoFetch - 是否在权限未加载时自动获取(默认 true) * @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
* @returns boolean - 用户是否拥有该权限 * @returns boolean - 用户是否拥有该权限
* *
* @example * @example
* ```tsx * ```tsx
* function MyComponent() { * function MyComponent() {
* const canEdit = usePermission('can_edit_media') * const canEdit = usePermission('can_edit_media')
* *
* if (canEdit) { * if (canEdit) {
* return <EditButton /> * return <EditButton />
* } * }
@@ -30,26 +30,24 @@ export function usePermission(
const { autoFetch = true } = options const { autoFetch = true } = options
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染 // selector 订阅:store 任何无关字段变化不会触发本组件重渲染
const hasPermission = usePermissionStore((s) => s.hasPermission) const hasPermission = usePermissionStore((s) => s.hasPermission)
const isSuper = usePermissionStore((s) => s.isSuper)
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions) const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
const tier = useAuthStore((state) => state.tier)
const role = useAuthStore((state) => state.user?.role) const role = useAuthStore((state) => state.user?.role)
const isAuthenticated = useAuthStore((state) => state.token !== null) const isAuthenticated = useAuthStore((state) => state.token !== null)
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin' const isAdmin = role === 'admin'
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包, // 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。 // 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
useEffect(() => { useEffect(() => {
if (!hasSuperAccess && isAuthenticated && autoFetch) { if (!isAdmin && isAuthenticated && autoFetch) {
const { permissions, isLoading } = usePermissionStore.getState() const { permissions, isLoading } = usePermissionStore.getState()
if (Object.keys(permissions).length === 0 && !isLoading) { if (Object.keys(permissions).length === 0 && !isLoading) {
fetchPermissions() fetchPermissions()
} }
} }
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated]) }, [autoFetch, fetchPermissions, isAdmin, isAuthenticated])
// 超级用户有所有权限 // 管理员拥有全部能力;plus / is_super 不能用来展示刮削、整理、删除等管理入口
if (hasSuperAccess) { if (isAdmin) {
return true return true
} }
@@ -63,18 +61,18 @@ export function usePermission(
/** /**
* usePermissions hook - 获取所有权限 * usePermissions hook - 获取所有权限
* *
* @returns 权限状态和检查函数 * @returns 权限状态和检查函数
* *
* @example * @example
* ```tsx * ```tsx
* function MyComponent() { * function MyComponent() {
* const { permissions, isSuper, check } = usePermissions() * const { permissions, isSuper, check } = usePermissions()
* *
* if (isSuper) { * if (isSuper) {
* return <AdminPanel /> * return <AdminPanel />
* } * }
* *
* return ( * return (
* <div> * <div>
* {check('can_view_dashboard') && <Dashboard />} * {check('can_view_dashboard') && <Dashboard />}
@@ -87,15 +85,14 @@ export function usePermission(
export function usePermissions() { export function usePermissions() {
// selector 订阅:只关注 permissions/isSuper/isLoading 变化 // selector 订阅:只关注 permissions/isSuper/isLoading 变化
const permissions = usePermissionStore((s) => s.permissions) const permissions = usePermissionStore((s) => s.permissions)
const isSuper = usePermissionStore((s) => s.isSuper)
const isLoading = usePermissionStore((s) => s.isLoading) const isLoading = usePermissionStore((s) => s.isLoading)
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions) const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
const tier = useAuthStore((state) => state.tier)
const role = useAuthStore((state) => state.user?.role) const role = useAuthStore((state) => state.user?.role)
const isAuthenticated = useAuthStore((state) => state.token !== null) const isAuthenticated = useAuthStore((state) => state.token !== null)
const isAdmin = role === 'admin'
const check = (key: string): boolean => { const check = (key: string): boolean => {
if (isSuper || tier === 'plus' || role === 'admin') { if (isAdmin) {
return true return true
} }
return permissions[key] === true return permissions[key] === true
@@ -103,7 +100,8 @@ export function usePermissions() {
return { return {
permissions, permissions,
isSuper: isSuper || tier === 'plus' || role === 'admin', // Keep the field name for callers, but only admins are treated as full-access.
isSuper: isAdmin,
isLoading, isLoading,
check, check,
refetch: fetchPermissions, refetch: fetchPermissions,
@@ -113,10 +111,10 @@ export function usePermissions() {
/** /**
* usePermissionMany hook - 批量检查多个权限 * usePermissionMany hook - 批量检查多个权限
* *
* @param keys - 权限键数组 * @param keys - 权限键数组
* @returns 每个权限的布尔值映射 * @returns 每个权限的布尔值映射
* *
* @example * @example
* ```tsx * ```tsx
* function MyComponent() { * function MyComponent() {
@@ -125,7 +123,7 @@ export function usePermissions() {
* 'can_manage_users', * 'can_manage_users',
* 'can_access_settings', * 'can_access_settings',
* ]) * ])
* *
* return ( * return (
* <div> * <div>
* {perms['can_edit_media'] && <EditButton />} * {perms['can_edit_media'] && <EditButton />}
@@ -137,7 +135,7 @@ export function usePermissions() {
*/ */
export function usePermissionMany(keys: string[]): Record<string, boolean> { export function usePermissionMany(keys: string[]): Record<string, boolean> {
const { check } = usePermissions() const { check } = usePermissions()
return keys.reduce((acc, key) => { return keys.reduce((acc, key) => {
acc[key] = check(key) acc[key] = check(key)
return acc return acc
+6 -2
View File
@@ -5,6 +5,7 @@ import { toolsAPI } from '../api/tools'
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog' import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference' import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
import { useAuthStore } from '../stores/auth'
import { import {
LibrariesContent, LibrariesContent,
LibrariesEmptyState, LibrariesEmptyState,
@@ -18,6 +19,7 @@ import { sortLibraryPreviews } from '../utils/pinnedLibraries'
import { partitionPreviewIDs } from '../utils/remoteEmby' import { partitionPreviewIDs } from '../utils/remoteEmby'
export function LibrariesPage() { export function LibrariesPage() {
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
const [libraries, setLibraries] = useState<Library[]>([]) const [libraries, setLibraries] = useState<Library[]>([])
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({}) const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries() const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
@@ -103,7 +105,7 @@ export function LibrariesPage() {
}, []) }, [])
async function handleRepairRescrape() { async function handleRepairRescrape() {
if (repairing) return if (!isAdmin || repairing) return
setRepairing(true) setRepairing(true)
setRepairMsg('') setRepairMsg('')
try { try {
@@ -117,6 +119,7 @@ export function LibrariesPage() {
} }
const handleManageLibraries = async () => { const handleManageLibraries = async () => {
if (!isAdmin) return
await openManageLibrariesDialog() await openManageLibrariesDialog()
await loadLibraries({ force: true }) await loadLibraries({ force: true })
} }
@@ -154,6 +157,7 @@ export function LibrariesPage() {
<LibrariesHeader <LibrariesHeader
previewCount={previews.length} previewCount={previews.length}
total={total} total={total}
isAdmin={isAdmin}
repairMsg={repairMsg} repairMsg={repairMsg}
repairEpisodeArtwork={repairEpisodeArtwork} repairEpisodeArtwork={repairEpisodeArtwork}
repairing={repairing} repairing={repairing}
@@ -163,7 +167,7 @@ export function LibrariesPage() {
/> />
{previews.length === 0 ? ( {previews.length === 0 ? (
<LibrariesEmptyState /> <LibrariesEmptyState isAdmin={isAdmin} />
) : ( ) : (
<LibrariesContent <LibrariesContent
previews={sortedPreviews} previews={sortedPreviews}
+42 -36
View File
@@ -9,6 +9,7 @@ import type { LibraryPreview } from './librariesPageModel'
export function LibrariesHeader({ export function LibrariesHeader({
previewCount, previewCount,
total, total,
isAdmin,
repairMsg, repairMsg,
repairEpisodeArtwork, repairEpisodeArtwork,
repairing, repairing,
@@ -18,6 +19,7 @@ export function LibrariesHeader({
}: { }: {
previewCount: number previewCount: number
total: number total: number
isAdmin: boolean
repairMsg: string repairMsg: string
repairEpisodeArtwork: boolean repairEpisodeArtwork: boolean
repairing: boolean repairing: boolean
@@ -33,49 +35,53 @@ export function LibrariesHeader({
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。 共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> {isAdmin && (
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>} <div className="flex flex-wrap items-center gap-2 sm:gap-3">
<EpisodeArtworkToggle {repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
checked={repairEpisodeArtwork} <EpisodeArtworkToggle
onChange={onRepairEpisodeArtworkChange} checked={repairEpisodeArtwork}
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片" onChange={onRepairEpisodeArtworkChange}
className="h-9 sm:h-10 text-xs sm:text-sm" title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
/> className="h-9 sm:h-10 text-xs sm:text-sm"
<button />
type="button" <button
onClick={onRepairRescrape} type="button"
disabled={repairing} onClick={onRepairRescrape}
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60" disabled={repairing}
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库" className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
> title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} /> >
{repairing ? '正在启动…' : '全库修复+重刮'} <RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
</button> {repairing ? '正在启动…' : '全库修复+重刮'}
<Link </button>
to="/scraper/queue" <Link
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm" to="/scraper/queue"
title="查看正在进行的刮削任务与进度" className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
> title="查看正在进行的刮削任务与进度"
<Sparkles size={14} className="text-brand-500" /> >
<span>刮削队列</span> <Sparkles size={14} className="text-brand-500" />
</Link> <span>刮削队列</span>
<button </Link>
type="button" <button
onClick={onManageLibraries} type="button"
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm" onClick={onManageLibraries}
> className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
管理媒体库 >
</button> 管理媒体库
</div> </button>
</div>
)}
</div> </div>
) )
} }
export function LibrariesEmptyState() { export function LibrariesEmptyState({ isAdmin = false }: { isAdmin?: boolean }) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] py-24 text-center"> <div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[var(--app-border)] bg-[var(--app-panel)] py-24 text-center">
<LibraryIcon className="mb-4 h-12 w-12 text-[var(--app-muted)]" /> <LibraryIcon className="mb-4 h-12 w-12 text-[var(--app-muted)]" />
<p className="text-sm text-[var(--app-muted)]">暂无媒体库,请到管理后台添加目录。</p> <p className="text-sm text-[var(--app-muted)]">
{isAdmin ? '暂无媒体库,请到管理后台添加目录。' : '暂无可用媒体库。'}
</p>
</div> </div>
) )
} }
+2 -2
View File
@@ -30,7 +30,7 @@ export function LibraryMovieActions({
} }
return ( return (
<> <div className="hidden flex-wrap justify-end gap-1 sm:flex">
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}> <button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
<Sparkles size={13} /> <Sparkles size={13} />
</button> </button>
@@ -46,6 +46,6 @@ export function LibraryMovieActions({
<button title="删除" disabled={busy} onClick={() => onDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}> <button title="删除" disabled={busy} onClick={() => onDelete(media)} className={`${buttonClass} hover:!bg-red-50 hover:!text-red-500`}>
<Trash2 size={13} /> <Trash2 size={13} />
</button> </button>
</> </div>
) )
} }
+3 -2
View File
@@ -53,8 +53,9 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
hasPermission: (key: string) => { hasPermission: (key: string) => {
const state = get() const state = get()
// Super user (admin or plus) has all permissions // Only admins implicitly have every capability in the UI.
if (state.isSuper) { // Plus / is_super must not reveal scrape/organize/delete controls.
if (state.role === 'admin') {
return true return true
} }
return (state.permissions ?? {})[key] === true return (state.permissions ?? {})[key] === true