mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
Merge pull request #34 from truewhile/cursor/hide-viewer-admin-ui
Cursor/hide viewer admin UI
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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} 项下载`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user