mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06: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 ""
|
||||
}
|
||||
|
||||
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
|
||||
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
|
||||
// 否则多用户会串到同一个 UserId 上。
|
||||
func embyEffectiveUserID(c *gin.Context) string {
|
||||
return embyUserID(c)
|
||||
}
|
||||
|
||||
const embyCompatSessionTTL = 30 * time.Minute
|
||||
|
||||
type embyCompatSession struct {
|
||||
|
||||
@@ -13,13 +13,7 @@ import (
|
||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
splitOpt := func(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -73,10 +67,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -106,13 +97,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||
if err != nil {
|
||||
@@ -126,13 +111,7 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||
if err != nil {
|
||||
@@ -147,13 +126,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc != nil && svc.Emby != nil {
|
||||
uid := firstQueryValue(c, "UserId", "userId")
|
||||
if uid == "" {
|
||||
uid = c.Param("userId")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -209,7 +182,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: c.Param("id"),
|
||||
Limit: 500,
|
||||
}
|
||||
@@ -230,7 +203,7 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
parentID = c.Param("id")
|
||||
}
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: parentID,
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
|
||||
@@ -13,10 +13,7 @@ import (
|
||||
|
||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -47,10 +44,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
return
|
||||
}
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
ctx := c.Request.Context()
|
||||
// The official-format route carries a :format suffix (Stream.ass /
|
||||
// Stream.vtt); prefer it for the Content-Type when present, otherwise
|
||||
|
||||
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
@@ -116,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
|
||||
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
|
||||
@@ -10,10 +10,7 @@ import (
|
||||
|
||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -205,6 +205,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||
return e.favoriteItems(ctx, p)
|
||||
}
|
||||
// 继续观看必须走 MeBox 本地 PlaybackHistory,不能转发到远程共用账号。
|
||||
if containsEmbyFilter(p.Filters, "IsResumable") {
|
||||
return e.resumableItems(ctx, p)
|
||||
}
|
||||
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
|
||||
@@ -38,14 +38,6 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite {
|
||||
userData, _ := out["UserData"].(map[string]any)
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
out["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
|
||||
@@ -466,12 +458,26 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
mediaIDs := make([]string, 0, len(hist))
|
||||
localIDs := make([]string, 0, len(hist))
|
||||
for _, h := range hist {
|
||||
mediaIDs = append(mediaIDs, h.MediaID)
|
||||
if !IsEmbyRemoteID(h.MediaID) {
|
||||
localIDs = append(localIDs, h.MediaID)
|
||||
}
|
||||
}
|
||||
favSet := map[string]bool{}
|
||||
if len(mediaIDs) > 0 {
|
||||
var favs []model.Favorite
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
|
||||
Find(&favs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, fav := range favs {
|
||||
favSet[fav.MediaID] = true
|
||||
}
|
||||
}
|
||||
byID := map[string]*model.Media{}
|
||||
if len(localIDs) > 0 {
|
||||
var medias []model.Media
|
||||
@@ -498,7 +504,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
continue
|
||||
}
|
||||
localTotal++
|
||||
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, h.PositionMs, false)})
|
||||
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, favSet[h.MediaID], h.PositionMs, false)})
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
|
||||
@@ -559,7 +565,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
continue
|
||||
}
|
||||
}
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &f.hist)
|
||||
item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
|
||||
slots[i].item = item
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
|
||||
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。只写入 MeBox 本地 favourites 表,
|
||||
// 按 user_id 隔离;挂载远程 Emby 共用账号,不能再透传收藏以免串用户。
|
||||
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
@@ -22,15 +22,8 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
|
||||
}
|
||||
|
||||
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
||||
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
|
||||
// 远程挂载条目同样只落本地 PlaybackHistory,按 MeBox 用户隔离。
|
||||
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !played {
|
||||
err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
@@ -40,15 +33,20 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
|
||||
}
|
||||
return err
|
||||
}
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
dur := int64(0)
|
||||
if IsEmbyRemoteID(mediaID) {
|
||||
dur = remoteItemDurationMs(ctx, e, mediaID)
|
||||
} else {
|
||||
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || m == nil {
|
||||
return errors.New("media not found")
|
||||
}
|
||||
dur = int64(m.DurationSec) * 1000
|
||||
}
|
||||
dur := int64(m.DurationSec) * 1000
|
||||
if dur <= 0 {
|
||||
dur = 1
|
||||
}
|
||||
err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||
err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
PositionMs: dur,
|
||||
@@ -62,6 +60,36 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl
|
||||
return err
|
||||
}
|
||||
|
||||
func remoteItemDurationMs(ctx context.Context, e *EmbyService, mediaID string) int64 {
|
||||
if e == nil || e.remote == nil || !IsEmbyRemoteID(mediaID) {
|
||||
return 0
|
||||
}
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
return 0
|
||||
}
|
||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
if err != nil || item == nil {
|
||||
return 0
|
||||
}
|
||||
switch ticks := item["RunTimeTicks"].(type) {
|
||||
case float64:
|
||||
if ticks > 0 {
|
||||
return int64(ticks) / 10_000
|
||||
}
|
||||
case int64:
|
||||
if ticks > 0 {
|
||||
return ticks / 10_000
|
||||
}
|
||||
case int:
|
||||
if ticks > 0 {
|
||||
return int64(ticks) / 10_000
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
|
||||
// 不携带 PlaySessionId 的旧调用仍保持兼容。
|
||||
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
|
||||
@@ -138,8 +166,9 @@ func embyPlaySessionStartedAtMs(playSessionID string) int64 {
|
||||
return value
|
||||
}
|
||||
|
||||
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
|
||||
// and favourite state to remote Emby payloads.
|
||||
// mergeRemoteUserData overlays the current MeBox user's locally recorded
|
||||
// playback and favourite state onto remote Emby payloads. Remote mounts share
|
||||
// one upstream Emby account, so upstream UserData must never leak across MeBox users.
|
||||
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
|
||||
if strings.TrimSpace(userID) == "" || payload == nil {
|
||||
return nil
|
||||
@@ -178,18 +207,7 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
|
||||
}
|
||||
for _, item := range items {
|
||||
id, _ := item["Id"].(string)
|
||||
userData, _ := item["UserData"].(map[string]any)
|
||||
if h := byMediaID[id]; h != nil {
|
||||
item["UserData"] = mergedRemoteUserData(userData, h)
|
||||
userData, _ = item["UserData"].(map[string]any)
|
||||
}
|
||||
if favSet[id] {
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
item["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
}
|
||||
item["UserData"] = applyMeBoxUserData(item["UserData"], byMediaID[id], favSet[id])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -221,12 +239,37 @@ func remoteItemMaps(payload any) []map[string]any {
|
||||
}
|
||||
|
||||
func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any {
|
||||
favorite := false
|
||||
if existing, ok := raw.(map[string]any); ok {
|
||||
if v, ok := existing["IsFavorite"].(bool); ok {
|
||||
favorite = v
|
||||
}
|
||||
}
|
||||
return applyMeBoxUserData(raw, history, favorite)
|
||||
}
|
||||
|
||||
// applyMeBoxUserData rebuilds UserData for a remote item using only MeBox-local
|
||||
// per-user state. Shared upstream Emby favourite/progress fields are discarded.
|
||||
func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool) map[string]any {
|
||||
userData := map[string]any{}
|
||||
if existing, ok := raw.(map[string]any); ok {
|
||||
for key, value := range existing {
|
||||
userData[key] = value
|
||||
switch key {
|
||||
case "IsFavorite", "PlaybackPositionTicks", "Played", "PlayedPercentage", "PlayCount", "LastPlayedDate":
|
||||
continue
|
||||
default:
|
||||
userData[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
userData["IsFavorite"] = favorite
|
||||
if history == nil {
|
||||
userData["PlaybackPositionTicks"] = int64(0)
|
||||
userData["Played"] = false
|
||||
userData["PlayedPercentage"] = float64(0)
|
||||
userData["PlayCount"] = 0
|
||||
return userData
|
||||
}
|
||||
duration := history.DurationMs
|
||||
position := history.PositionMs
|
||||
percentage := float64(0)
|
||||
@@ -237,18 +280,9 @@ func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]an
|
||||
userData["Played"] = history.Completed
|
||||
userData["PlayedPercentage"] = percentage
|
||||
if history.Completed {
|
||||
playCount := 0
|
||||
switch value := userData["PlayCount"].(type) {
|
||||
case int:
|
||||
playCount = value
|
||||
case int64:
|
||||
playCount = int(value)
|
||||
case float64:
|
||||
playCount = int(value)
|
||||
}
|
||||
if playCount < 1 {
|
||||
userData["PlayCount"] = 1
|
||||
}
|
||||
userData["PlayCount"] = 1
|
||||
} else {
|
||||
userData["PlayCount"] = 0
|
||||
}
|
||||
return userData
|
||||
}
|
||||
|
||||
@@ -18,16 +18,18 @@ func TestMergedRemoteUserData(t *testing.T) {
|
||||
preserve any
|
||||
}{
|
||||
{
|
||||
name: "in-progress preserves remote fields",
|
||||
name: "in-progress preserves non-user remote fields only",
|
||||
raw: map[string]any{
|
||||
"PlayCount": 2,
|
||||
"Custom": "remote-value",
|
||||
"PlayCount": 2,
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(999),
|
||||
"Custom": "remote-value",
|
||||
},
|
||||
history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000},
|
||||
position: 250_000_000,
|
||||
played: false,
|
||||
percent: 25,
|
||||
count: 2,
|
||||
count: 0,
|
||||
preserve: "remote-value",
|
||||
},
|
||||
{
|
||||
@@ -59,10 +61,47 @@ func TestMergedRemoteUserData(t *testing.T) {
|
||||
if tt.preserve != nil && out["Custom"] != tt.preserve {
|
||||
t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve)
|
||||
}
|
||||
if out["IsFavorite"] != false && out["IsFavorite"] != true {
|
||||
t.Fatalf("IsFavorite missing: %#v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMeBoxUserDataClearsSharedRemoteState(t *testing.T) {
|
||||
out := applyMeBoxUserData(map[string]any{
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(42_000_000),
|
||||
"Played": true,
|
||||
"PlayedPercentage": 80.0,
|
||||
"PlayCount": 3,
|
||||
"Key": "keep",
|
||||
}, nil, false)
|
||||
if out["IsFavorite"] != false {
|
||||
t.Fatalf("IsFavorite = %#v, want false", out["IsFavorite"])
|
||||
}
|
||||
if out["PlaybackPositionTicks"] != int64(0) {
|
||||
t.Fatalf("PlaybackPositionTicks = %#v, want 0", out["PlaybackPositionTicks"])
|
||||
}
|
||||
if out["Played"] != false {
|
||||
t.Fatalf("Played = %#v, want false", out["Played"])
|
||||
}
|
||||
if out["PlayedPercentage"] != float64(0) {
|
||||
t.Fatalf("PlayedPercentage = %#v, want 0", out["PlayedPercentage"])
|
||||
}
|
||||
if out["PlayCount"] != 0 {
|
||||
t.Fatalf("PlayCount = %#v, want 0", out["PlayCount"])
|
||||
}
|
||||
if out["Key"] != "keep" {
|
||||
t.Fatalf("Key = %#v, want keep", out["Key"])
|
||||
}
|
||||
|
||||
fav := applyMeBoxUserData(map[string]any{"IsFavorite": false}, nil, true)
|
||||
if fav["IsFavorite"] != true {
|
||||
t.Fatalf("favorite overlay IsFavorite = %#v, want true", fav["IsFavorite"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
|
||||
remoteID := EncodeEmbyRemoteID("mount-1", "item-1")
|
||||
payload := map[string]any{
|
||||
@@ -108,3 +147,57 @@ func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
|
||||
t.Fatalf("expected 95%% progress to be completed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkPlayedStoresRemoteItemLocallyPerUser(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
remoteID := EncodeEmbyRemoteID("mount-test", "item-played")
|
||||
alice := &model.User{Username: "alice_played", Role: "user", Tier: "free", IsActive: true}
|
||||
bob := &model.User{Username: "bob_played", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := svc.repo.User.Create(t.Context(), alice); err != nil {
|
||||
t.Fatalf("create alice: %v", err)
|
||||
}
|
||||
if err := svc.repo.User.Create(t.Context(), bob); err != nil {
|
||||
t.Fatalf("create bob: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.MarkPlayed(t.Context(), alice.ID, remoteID, true); err != nil {
|
||||
t.Fatalf("mark played: %v", err)
|
||||
}
|
||||
|
||||
var aliceRows, bobRows int64
|
||||
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", alice.ID, remoteID).Count(&aliceRows)
|
||||
_ = svc.repo.DB.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", bob.ID, remoteID).Count(&bobRows)
|
||||
if aliceRows != 1 {
|
||||
t.Fatalf("alice history rows = %d, want 1", aliceRows)
|
||||
}
|
||||
if bobRows != 0 {
|
||||
t.Fatalf("bob should not see alice remote played state, rows=%d", bobRows)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"Id": remoteID,
|
||||
"UserData": map[string]any{
|
||||
"IsFavorite": true,
|
||||
"PlaybackPositionTicks": int64(50_000_000),
|
||||
"Played": true,
|
||||
},
|
||||
}
|
||||
if err := svc.mergeRemoteUserData(t.Context(), bob.ID, payload); err != nil {
|
||||
t.Fatalf("merge for bob: %v", err)
|
||||
}
|
||||
bobData := payload["UserData"].(map[string]any)
|
||||
if bobData["IsFavorite"] != false {
|
||||
t.Fatalf("bob IsFavorite leaked: %#v", bobData)
|
||||
}
|
||||
if bobData["Played"] != false || bobData["PlaybackPositionTicks"] != int64(0) {
|
||||
t.Fatalf("bob playback leaked: %#v", bobData)
|
||||
}
|
||||
|
||||
if err := svc.mergeRemoteUserData(t.Context(), alice.ID, payload); err != nil {
|
||||
t.Fatalf("merge for alice: %v", err)
|
||||
}
|
||||
aliceData := payload["UserData"].(map[string]any)
|
||||
if aliceData["Played"] != true {
|
||||
t.Fatalf("alice Played = %#v, want true", aliceData["Played"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,14 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// SyncUserFavorite keeps favourite state aligned across the local favourites table
|
||||
// and the upstream remote Emby server for mounted items.
|
||||
func SyncUserFavorite(ctx context.Context, repo *repository.Container, remote *EmbyRemoteService, userID, mediaID string, favorite bool) error {
|
||||
// SyncUserFavorite keeps favourite state in the local favourites table, keyed
|
||||
// by MeBox user_id. Remote Emby mounts share one upstream account, so favourite
|
||||
// state is intentionally not proxied upstream.
|
||||
func SyncUserFavorite(ctx context.Context, repo *repository.Container, _ *EmbyRemoteService, userID, mediaID string, favorite bool) error {
|
||||
if repo == nil || userID == "" || mediaID == "" {
|
||||
return errors.New("missing favourite sync inputs")
|
||||
}
|
||||
if err := setLocalFavorite(ctx, repo, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
if favorite || IsEmbyRemoteID(mediaID) {
|
||||
if err := proxyRemoteFavorite(ctx, remote, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return setLocalFavorite(ctx, repo, userID, mediaID, favorite)
|
||||
}
|
||||
|
||||
// IsUserFavorite reports whether the user has favourited mediaID locally.
|
||||
@@ -57,18 +50,3 @@ func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, m
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
Delete(&model.Favorite{}).Error
|
||||
}
|
||||
|
||||
func proxyRemoteFavorite(ctx context.Context, remote *EmbyRemoteService, mediaID string, favorite bool) error {
|
||||
if remote == nil || !IsEmbyRemoteID(mediaID) {
|
||||
return nil
|
||||
}
|
||||
mountID, remoteItemID, ok := DecodeEmbyRemoteID(mediaID)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
_, acct, err := remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return remote.ProxySetFavorite(ctx, acct, remoteItemID, favorite)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function GlobalEvents() {
|
||||
}
|
||||
}
|
||||
if (topic === 'scrape' && p.finished) {
|
||||
if (role !== 'admin') return
|
||||
const processed = Number(p.processed ?? 0)
|
||||
const matched = Number(p.matched ?? 0)
|
||||
const failed = Number(p.failed ?? 0)
|
||||
@@ -51,6 +52,7 @@ export function GlobalEvents() {
|
||||
}
|
||||
}
|
||||
if (topic === 'subscription') {
|
||||
if (role !== 'admin') return
|
||||
const queued = (p.queued as number | undefined) ?? 0
|
||||
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { User } from '../types'
|
||||
|
||||
export function useLayoutPermissions(user: User | null | undefined) {
|
||||
const permissions = usePermissionStore((state) => state.permissions)
|
||||
const isSuper = usePermissionStore((state) => state.isSuper)
|
||||
const isPermissionLoading = usePermissionStore((state) => state.isLoading)
|
||||
const fetchPermissions = usePermissionStore((state) => state.fetchPermissions)
|
||||
|
||||
@@ -17,8 +16,8 @@ export function useLayoutPermissions(user: User | null | undefined) {
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
const can = useCallback(
|
||||
(key: string) => isAdmin || isSuper || (permissions ?? {})[key] === true,
|
||||
[isAdmin, isSuper, permissions],
|
||||
(key: string) => isAdmin || (permissions ?? {})[key] === true,
|
||||
[isAdmin, permissions],
|
||||
)
|
||||
|
||||
return { can, isAdmin }
|
||||
|
||||
@@ -30,26 +30,24 @@ export function usePermission(
|
||||
const { autoFetch = true } = options
|
||||
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
|
||||
const hasPermission = usePermissionStore((s) => s.hasPermission)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
|
||||
const isAdmin = role === 'admin'
|
||||
|
||||
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
||||
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
||||
useEffect(() => {
|
||||
if (!hasSuperAccess && isAuthenticated && autoFetch) {
|
||||
if (!isAdmin && isAuthenticated && autoFetch) {
|
||||
const { permissions, isLoading } = usePermissionStore.getState()
|
||||
if (Object.keys(permissions).length === 0 && !isLoading) {
|
||||
fetchPermissions()
|
||||
}
|
||||
}
|
||||
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated])
|
||||
}, [autoFetch, fetchPermissions, isAdmin, isAuthenticated])
|
||||
|
||||
// 超级用户有所有权限
|
||||
if (hasSuperAccess) {
|
||||
// 管理员拥有全部能力;plus / is_super 不能用来展示刮削、整理、删除等管理入口
|
||||
if (isAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -87,15 +85,14 @@ export function usePermission(
|
||||
export function usePermissions() {
|
||||
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
|
||||
const permissions = usePermissionStore((s) => s.permissions)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const isLoading = usePermissionStore((s) => s.isLoading)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
const isAdmin = role === 'admin'
|
||||
|
||||
const check = (key: string): boolean => {
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
if (isAdmin) {
|
||||
return true
|
||||
}
|
||||
return permissions[key] === true
|
||||
@@ -103,7 +100,8 @@ export function usePermissions() {
|
||||
|
||||
return {
|
||||
permissions,
|
||||
isSuper: isSuper || tier === 'plus' || role === 'admin',
|
||||
// Keep the field name for callers, but only admins are treated as full-access.
|
||||
isSuper: isAdmin,
|
||||
isLoading,
|
||||
check,
|
||||
refetch: fetchPermissions,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toolsAPI } from '../api/tools'
|
||||
import { openManageLibrariesDialog } from '../components/manageLibrariesDialog'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import {
|
||||
LibrariesContent,
|
||||
LibrariesEmptyState,
|
||||
@@ -18,6 +19,7 @@ import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||
@@ -103,7 +105,7 @@ export function LibrariesPage() {
|
||||
}, [])
|
||||
|
||||
async function handleRepairRescrape() {
|
||||
if (repairing) return
|
||||
if (!isAdmin || repairing) return
|
||||
setRepairing(true)
|
||||
setRepairMsg('')
|
||||
try {
|
||||
@@ -117,6 +119,7 @@ export function LibrariesPage() {
|
||||
}
|
||||
|
||||
const handleManageLibraries = async () => {
|
||||
if (!isAdmin) return
|
||||
await openManageLibrariesDialog()
|
||||
await loadLibraries({ force: true })
|
||||
}
|
||||
@@ -154,6 +157,7 @@ export function LibrariesPage() {
|
||||
<LibrariesHeader
|
||||
previewCount={previews.length}
|
||||
total={total}
|
||||
isAdmin={isAdmin}
|
||||
repairMsg={repairMsg}
|
||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||
repairing={repairing}
|
||||
@@ -163,7 +167,7 @@ export function LibrariesPage() {
|
||||
/>
|
||||
|
||||
{previews.length === 0 ? (
|
||||
<LibrariesEmptyState />
|
||||
<LibrariesEmptyState isAdmin={isAdmin} />
|
||||
) : (
|
||||
<LibrariesContent
|
||||
previews={sortedPreviews}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { LibraryPreview } from './librariesPageModel'
|
||||
export function LibrariesHeader({
|
||||
previewCount,
|
||||
total,
|
||||
isAdmin,
|
||||
repairMsg,
|
||||
repairEpisodeArtwork,
|
||||
repairing,
|
||||
@@ -18,6 +19,7 @@ export function LibrariesHeader({
|
||||
}: {
|
||||
previewCount: number
|
||||
total: number
|
||||
isAdmin: boolean
|
||||
repairMsg: string
|
||||
repairEpisodeArtwork: boolean
|
||||
repairing: boolean
|
||||
@@ -33,49 +35,53 @@ export function LibrariesHeader({
|
||||
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||
<EpisodeArtworkToggle
|
||||
checked={repairEpisodeArtwork}
|
||||
onChange={onRepairEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
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 ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link
|
||||
to="/scraper/queue"
|
||||
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>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageLibraries}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
>
|
||||
管理媒体库
|
||||
</button>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||
<EpisodeArtworkToggle
|
||||
checked={repairEpisodeArtwork}
|
||||
onChange={onRepairEpisodeArtworkChange}
|
||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRepairRescrape}
|
||||
disabled={repairing}
|
||||
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 ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link
|
||||
to="/scraper/queue"
|
||||
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>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageLibraries}
|
||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||
>
|
||||
管理媒体库
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LibrariesEmptyState() {
|
||||
export function LibrariesEmptyState({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,8 +53,9 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
|
||||
hasPermission: (key: string) => {
|
||||
const state = get()
|
||||
// Super user (admin or plus) has all permissions
|
||||
if (state.isSuper) {
|
||||
// Only admins implicitly have every capability in the UI.
|
||||
// Plus / is_super must not reveal scrape/organize/delete controls.
|
||||
if (state.role === 'admin') {
|
||||
return true
|
||||
}
|
||||
return (state.permissions ?? {})[key] === true
|
||||
|
||||
Reference in New Issue
Block a user