mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 05:16:38 +08:00
修复
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)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user