mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f2760728 | |||
| ea732e5bd7 | |||
| 4e02692aeb | |||
| e8c710c261 |
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -23,18 +22,18 @@ import (
|
||||
func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
// Check current state.
|
||||
var existing model.Favorite
|
||||
err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
userID := toString(uid)
|
||||
mediaID := c.Param("id")
|
||||
favorite, err := service.IsUserFavorite(c.Request.Context(), svc.Repo, userID, mediaID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if favorite {
|
||||
c.JSON(http.StatusOK, gin.H{"favourite": true})
|
||||
return
|
||||
}
|
||||
// Otherwise create.
|
||||
fav := &model.Favorite{UserID: toString(uid), MediaID: c.Param("id")}
|
||||
if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(fav).Error; err != nil {
|
||||
if err := svc.Playback.SetFavourite(c.Request.Context(), userID, mediaID, true); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -46,9 +45,7 @@ func addMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
|
||||
Delete(&model.Favorite{}).Error; err != nil {
|
||||
if err := svc.Playback.SetFavourite(c.Request.Context(), toString(uid), c.Param("id"), false); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -60,12 +57,12 @@ func removeMediaFavoriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func getMediaFavoriteStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
var n int64
|
||||
_ = svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Model(&model.Favorite{}).
|
||||
Where("user_id = ? AND media_id = ?", uid, c.Param("id")).
|
||||
Count(&n).Error
|
||||
c.JSON(http.StatusOK, gin.H{"favourite": n > 0})
|
||||
favorite, err := service.IsUserFavorite(c.Request.Context(), svc.Repo, toString(uid), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"favourite": favorite})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if e.remote != nil {
|
||||
// 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。
|
||||
if IsEmbyRemoteID(p.ParentID) {
|
||||
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||
return e.favoriteItems(ctx, p)
|
||||
}
|
||||
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
@@ -171,6 +174,9 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if containsEmbyFilter(p.Filters, "IsResumable") {
|
||||
return e.resumableItems(ctx, p)
|
||||
}
|
||||
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||||
return e.favoriteItems(ctx, p)
|
||||
}
|
||||
|
||||
if len(p.IDs) > 0 {
|
||||
items := make([]map[string]any, 0, len(p.IDs))
|
||||
|
||||
@@ -28,6 +28,14 @@ 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 {
|
||||
@@ -180,6 +188,137 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
||||
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
||||
}
|
||||
|
||||
// favoriteItems returns favourited media for Emby clients, including mounted
|
||||
// remote items stored only in the local favourites table.
|
||||
func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
if p.Limit <= 0 || p.Limit > 500 {
|
||||
p.Limit = 50
|
||||
}
|
||||
if p.StartIndex < 0 {
|
||||
p.StartIndex = 0
|
||||
}
|
||||
if strings.TrimSpace(p.UserID) == "" {
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
var favs []model.Favorite
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ?", p.UserID).
|
||||
Order("created_at desc").
|
||||
Find(&favs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(favs) == 0 {
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
localIDs := make([]string, 0, len(favs))
|
||||
for _, fav := range favs {
|
||||
if !IsEmbyRemoteID(fav.MediaID) {
|
||||
localIDs = append(localIDs, fav.MediaID)
|
||||
}
|
||||
}
|
||||
byID := map[string]*model.Media{}
|
||||
if len(localIDs) > 0 {
|
||||
var medias []model.Media
|
||||
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
|
||||
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||
if err := q.Find(&medias).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range medias {
|
||||
byID[medias[i].ID] = &medias[i]
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(favs))
|
||||
for _, fav := range favs {
|
||||
if m, ok := byID[fav.MediaID]; ok {
|
||||
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, m.LibraryID, m.SeriesID, nil) {
|
||||
continue
|
||||
}
|
||||
if p.SearchTerm != "" {
|
||||
needle := strings.ToLower(p.SearchTerm)
|
||||
if !strings.Contains(strings.ToLower(m.Title), needle) &&
|
||||
!strings.Contains(strings.ToLower(m.OriginalName), needle) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, e.itemPayload(ctx, m, true, 0))
|
||||
continue
|
||||
}
|
||||
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
|
||||
continue
|
||||
}
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
continue
|
||||
}
|
||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
if err != nil || item == nil {
|
||||
continue
|
||||
}
|
||||
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, "", "", item) {
|
||||
continue
|
||||
}
|
||||
if p.SearchTerm != "" {
|
||||
needle := strings.ToLower(p.SearchTerm)
|
||||
name, _ := item["Name"].(string)
|
||||
orig, _ := item["OriginalTitle"].(string)
|
||||
if !strings.Contains(strings.ToLower(name), needle) &&
|
||||
!strings.Contains(strings.ToLower(orig), needle) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
userData, _ := item["UserData"].(map[string]any)
|
||||
if userData == nil {
|
||||
userData = map[string]any{}
|
||||
item["UserData"] = userData
|
||||
}
|
||||
userData["IsFavorite"] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
total := int64(len(items))
|
||||
if p.StartIndex >= len(items) {
|
||||
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
end := minInt(p.StartIndex+p.Limit, len(items))
|
||||
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||
}
|
||||
|
||||
func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaID, libraryID, seriesID string, remoteItem map[string]any) bool {
|
||||
if parentID == "" {
|
||||
return true
|
||||
}
|
||||
if libraryID != "" {
|
||||
if libraryID == parentID || seriesID == parentID {
|
||||
return true
|
||||
}
|
||||
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
|
||||
if id == libraryID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if remoteItem == nil {
|
||||
return false
|
||||
}
|
||||
itemParent, _ := remoteItem["ParentId"].(string)
|
||||
itemSeries, _ := remoteItem["SeriesId"].(string)
|
||||
if itemParent == parentID || itemSeries == parentID || mediaID == parentID {
|
||||
return true
|
||||
}
|
||||
if !IsEmbyRemoteID(parentID) {
|
||||
return false
|
||||
}
|
||||
wantMountID, _, _ := DecodeEmbyRemoteID(parentID)
|
||||
gotMountID, _, _ := DecodeEmbyRemoteID(mediaID)
|
||||
return wantMountID != "" && gotMountID == wantMountID
|
||||
}
|
||||
|
||||
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
|
||||
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
if p.Limit <= 0 || p.Limit > 100 {
|
||||
|
||||
@@ -7,35 +7,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。远程 Emby 条目直接透传到对应
|
||||
// 服务器(本地不落库)。
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。挂载的远程 Emby 条目会同时写入
|
||||
// 本地 favourites 表并透传到对应远程服务器,保证网页与第三方 Emby 客户端一致。
|
||||
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
if err := e.ProxyRemoteSetFavorite(ctx, acctID, remoteID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if favorite {
|
||||
var f model.Favorite
|
||||
err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return e.repo.DB.WithContext(ctx).Create(&model.Favorite{
|
||||
UserID: userID, MediaID: mediaID,
|
||||
}).Error
|
||||
}
|
||||
if err := SyncUserFavorite(ctx, e.repo, e.remote, userID, mediaID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
Delete(&model.Favorite{}).Error
|
||||
e.invalidateEmbyItemsCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
||||
@@ -122,8 +104,7 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
|
||||
}
|
||||
|
||||
// mergeRemoteUserData applies the current MeBox user's locally recorded playback
|
||||
// state to remote Emby payloads. Remote metadata remains authoritative unless the
|
||||
// user has played the item through MeBox.
|
||||
// and favourite state to remote Emby payloads.
|
||||
func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error {
|
||||
if strings.TrimSpace(userID) == "" || payload == nil {
|
||||
return nil
|
||||
@@ -152,10 +133,27 @@ func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, pa
|
||||
for i := range histories {
|
||||
byMediaID[histories[i].MediaID] = &histories[i]
|
||||
}
|
||||
var favs []model.Favorite
|
||||
if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id IN ?", userID, ids).Find(&favs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
favSet := make(map[string]bool, len(favs))
|
||||
for _, fav := range favs {
|
||||
favSet[fav.MediaID] = true
|
||||
}
|
||||
for _, item := range items {
|
||||
id, _ := item["Id"].(string)
|
||||
userData, _ := item["UserData"].(map[string]any)
|
||||
if h := byMediaID[id]; h != nil {
|
||||
item["UserData"] = mergedRemoteUserData(item["UserData"], h)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"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 {
|
||||
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
|
||||
}
|
||||
|
||||
// IsUserFavorite reports whether the user has favourited mediaID locally.
|
||||
func IsUserFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string) (bool, error) {
|
||||
if repo == nil || userID == "" || mediaID == "" {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
err := repo.DB.WithContext(ctx).Model(&model.Favorite{}).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string, favorite bool) error {
|
||||
if favorite {
|
||||
var existing model.Favorite
|
||||
err := repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return repo.DB.WithContext(ctx).Create(&model.Favorite{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
}).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
return repo.DB.WithContext(ctx).
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestSyncUserFavoriteWritesLocalForRemoteID(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Favorite{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
userID := "user-1"
|
||||
remoteMediaID := EncodeEmbyRemoteID("mount-1", "remote-item-1")
|
||||
|
||||
if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, true); err != nil {
|
||||
t.Fatalf("SyncUserFavorite favorite: %v", err)
|
||||
}
|
||||
favorite, err := IsUserFavorite(context.Background(), repos, userID, remoteMediaID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsUserFavorite: %v", err)
|
||||
}
|
||||
if !favorite {
|
||||
t.Fatal("expected remote favourite to be stored locally")
|
||||
}
|
||||
|
||||
if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, false); err != nil {
|
||||
t.Fatalf("SyncUserFavorite unfavorite: %v", err)
|
||||
}
|
||||
favorite, err = IsUserFavorite(context.Background(), repos, userID, remoteMediaID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsUserFavorite after delete: %v", err)
|
||||
}
|
||||
if favorite {
|
||||
t.Fatal("expected remote favourite to be removed locally")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteItemsIncludesRemoteFavourites(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.User{}, &model.Library{}, &model.Media{}, &model.Favorite{})
|
||||
repos := repository.New(db)
|
||||
viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), viewer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteMediaID := EncodeEmbyRemoteID("mount-1", "remote-item-1")
|
||||
if err := db.Create(&model.Favorite{UserID: viewer.ID, MediaID: remoteMediaID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &EmbyService{repo: repos}
|
||||
out, err := svc.favoriteItems(t.Context(), ItemsParams{UserID: viewer.ID, Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("favoriteItems: %v", err)
|
||||
}
|
||||
total, _ := out["TotalRecordCount"].(int64)
|
||||
if total != 0 {
|
||||
// Without a wired remote service hydration is skipped, but local-only path
|
||||
// should not error and should not count unavailable remote rows.
|
||||
items, _ := out["Items"].([]map[string]any)
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("expected no hydrated remote rows without remote service, got %#v", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,20 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
|
||||
|
||||
// ToggleFavourite flips the favourite flag and reports the new state.
|
||||
func (p *PlaybackService) ToggleFavourite(ctx context.Context, userID, mediaID string) (bool, error) {
|
||||
return p.repo.Favorite.Toggle(ctx, userID, mediaID)
|
||||
current, err := IsUserFavorite(ctx, p.repo, userID, mediaID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
next := !current
|
||||
if err := p.SetFavourite(ctx, userID, mediaID, next); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// SetFavourite sets favourite state for a media item.
|
||||
func (p *PlaybackService) SetFavourite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||
return SyncUserFavorite(ctx, p.repo, p.remote, userID, mediaID, favorite)
|
||||
}
|
||||
|
||||
// ListFavourites returns every favourited media for a user.
|
||||
|
||||
@@ -119,6 +119,9 @@ func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string,
|
||||
return nil, err
|
||||
}
|
||||
normalized := filterPinnedLibraryIDs(normalizePinnedLibraryIDs(ids), accessible)
|
||||
if normalized == nil {
|
||||
normalized = []string{}
|
||||
}
|
||||
raw, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -146,6 +149,22 @@ func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility
|
||||
}
|
||||
out[lib.ID] = struct{}{}
|
||||
}
|
||||
// Mounted Emby libraries are not rows in the local libraries table; their
|
||||
// web IDs are embyremote~{mountID}~{remoteViewID}. Include enabled mounts
|
||||
// from the mount table so pinning them does not get stripped (and so a
|
||||
// pin-save that includes remotes cannot accidentally wipe local pins).
|
||||
if p.repo.EmbyMount != nil {
|
||||
mounts, err := p.repo.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, mount := range mounts {
|
||||
if !mount.Enabled || strings.TrimSpace(mount.RemoteViewID) == "" {
|
||||
continue
|
||||
}
|
||||
out[EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestProfilePinnedLibrariesFiltersInaccessibleAndPreservesOrder(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
@@ -59,3 +59,65 @@ func TestProfilePinnedLibrariesFiltersInaccessibleAndPreservesOrder(t *testing.T
|
||||
t.Fatalf("GetPinnedLibraryIDs = %v, want %v", loaded, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilePinnedLibrariesKeepsMountedEmbyAndLocalPins(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.EmbyMount{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := NewProfileService(zap.NewNop(), repos)
|
||||
|
||||
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), local); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
AccountID: "acct-1",
|
||||
RemoteViewID: "view-42",
|
||||
RemoteViewName: "Remote Movies",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
|
||||
disabled := &model.EmbyMount{
|
||||
AccountID: "acct-1",
|
||||
RemoteViewID: "view-99",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), disabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(disabled).Update("enabled", false).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabledID := EncodeEmbyRemoteID(disabled.ID, disabled.RemoteViewID)
|
||||
|
||||
got, err := svc.SetPinnedLibraryIDs(t.Context(), user.ID, []string{
|
||||
local.ID, remoteID, disabledID, "embyremote~missing~view",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetPinnedLibraryIDs: %v", err)
|
||||
}
|
||||
want := []string{local.ID, remoteID}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("SetPinnedLibraryIDs = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
loaded, err := svc.GetPinnedLibraryIDs(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPinnedLibraryIDs: %v", err)
|
||||
}
|
||||
if len(loaded) != len(want) || loaded[0] != want[0] || loaded[1] != want[1] {
|
||||
t.Fatalf("GetPinnedLibraryIDs = %v, want %v", loaded, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
loadPinnedLibraryIds,
|
||||
@@ -10,16 +10,26 @@ export function usePinnedLibraries() {
|
||||
const [pinnedIds, setPinnedIds] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const loadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadError(false)
|
||||
loadedRef.current = false
|
||||
loadPinnedLibraryIds()
|
||||
.then((ids) => {
|
||||
if (!cancelled) setPinnedIds(ids)
|
||||
if (cancelled) return
|
||||
loadedRef.current = true
|
||||
setPinnedIds(ids)
|
||||
setLoadError(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPinnedIds([])
|
||||
if (cancelled) return
|
||||
loadedRef.current = false
|
||||
setLoadError(true)
|
||||
// Keep any existing pins in memory; never replace a known server list with [].
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
@@ -30,6 +40,11 @@ export function usePinnedLibraries() {
|
||||
}, [])
|
||||
|
||||
const togglePin = useCallback(async (libraryId: string) => {
|
||||
// Avoid saving from an unloaded/failed state — that would overwrite the
|
||||
// server list with only the clicked id and can wipe existing local pins
|
||||
// when mounted Emby ids used to be filtered out server-side.
|
||||
if (!loadedRef.current || loading || loadError) return
|
||||
|
||||
let previous: string[] = []
|
||||
let optimistic: string[] = []
|
||||
setPinnedIds((current) => {
|
||||
@@ -46,7 +61,7 @@ export function usePinnedLibraries() {
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}, [])
|
||||
}, [loading, loadError])
|
||||
|
||||
return { pinnedIds, loading, syncing, togglePin }
|
||||
return { pinnedIds, loading, syncing, loadError, togglePin }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user