Compare commits

...

8 Commits

Author SHA1 Message Date
github-actions[bot] 9ab32c10ca chore: bump version to 0.0.93 [skip ci] 2026-09-03 02:26:16 +00:00
truewhile e872223b4e feat: honor pinned libraries in Emby client Views order (#24)
Sort /Users/.../Views so libraries the user pinned on the web appear
first for Infuse/Emby/Jellyfin clients. Unpinned libraries keep their
existing sort_order / remote-mount order.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 10:26:04 +08:00
github-actions[bot] c7441e06d4 chore: bump version to 0.0.92 [skip ci] 2026-09-03 02:13:21 +00:00
truewhile 936be24328 fix: stop pinned-library toggle from saving an empty list (#23)
The pin hook captured the optimistic ID list inside a React setState
updater. That updater can run after the following lines, so save ran with
optimistic=[] and overwrote server pins — matching the flash then wipe
when pinning a mounted Emby library after a local pin.

Compute previous/optimistic synchronously from a ref before calling setState.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 10:13:04 +08:00
github-actions[bot] b7f2760728 chore: bump version to 0.0.91 [skip ci] 2026-09-03 01:41:06 +00:00
truewhile ea732e5bd7 fix: allow pinning mounted Emby libraries without wiping local pins (#22)
accessibleLibraryIDSet only consulted the local libraries table, so
embyremote~ mount IDs were stripped on every GET/PUT. Saving a pin that
included a remote library could then persist a filtered list and clear
existing local pins from the client state. Include enabled Emby mounts in
the accessible set, and refuse pin toggles until the server list has
loaded successfully.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 09:40:52 +08:00
github-actions[bot] 4e02692aeb chore: bump version to 0.0.90 [skip ci] 2026-09-03 00:50:58 +00:00
truewhile e8c710c261 fix: sync mounted Emby favourites across web and Emby clients (#21)
Unify favourite writes through SyncUserFavorite so remote mounted media is
stored locally and proxied to the upstream Emby server. Emby clients now
query favourites from the local table (including remote pseudo IDs), and
remote item payloads merge local IsFavorite state.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-03 08:50:46 +08:00
13 changed files with 580 additions and 61 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.89
0.0.93
+16 -19
View File
@@ -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})
}
}
+6
View File
@@ -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))
+139
View File
@@ -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 {
+48 -1
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"sort"
"strings"
"time"
@@ -125,7 +126,8 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any {
}
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目,外加所有启用的
// 远程 Emby 挂载的媒体库(联邦聚合)。
// 远程 Emby 挂载的媒体库(联邦聚合)。顺序遵循用户置顶偏好:置顶库靠前,
// 未置顶保持原有 sort_order / 远程挂载顺序。
func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) {
libs, err := e.repo.Library.List(ctx)
if err != nil {
@@ -143,9 +145,54 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
for _, remote := range e.remoteViews(ctx) {
items = append(items, remote)
}
items = sortViewItemsByPinnedIDs(items, e.pinnedLibraryIDsForUser(ctx, userID))
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
func (e *EmbyService) pinnedLibraryIDsForUser(ctx context.Context, userID string) []string {
if e == nil || e.repo == nil || e.repo.User == nil || strings.TrimSpace(userID) == "" {
return nil
}
user, err := e.repo.User.FindByID(ctx, userID)
if err != nil || user == nil {
return nil
}
return user.DecodePinnedLibraryIDs()
}
func sortViewItemsByPinnedIDs(items []map[string]any, pinnedIDs []string) []map[string]any {
if len(items) < 2 || len(pinnedIDs) == 0 {
return items
}
rank := make(map[string]int, len(pinnedIDs))
for i, id := range pinnedIDs {
if id == "" {
continue
}
if _, exists := rank[id]; !exists {
rank[id] = i
}
}
if len(rank) == 0 {
return items
}
sorted := append([]map[string]any(nil), items...)
sort.SliceStable(sorted, func(i, j int) bool {
iID, _ := sorted[i]["Id"].(string)
jID, _ := sorted[j]["Id"].(string)
iRank, iPinned := rank[iID]
jRank, jPinned := rank[jID]
if iPinned != jPinned {
return iPinned
}
if iPinned && jPinned {
return iRank < jRank
}
return false
})
return sorted
}
// remoteViews 返回全部启用挂载的远程媒体库视图(只有显式挂载的库才出现)。
func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any {
if e == nil || e.remote == nil {
+24 -26
View File
@@ -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,66 @@
package service
import (
"encoding/json"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestViewsOrdersPinnedLibrariesFirst(t *testing.T) {
svc := newTestEmbyService(t)
first := model.Library{Name: "AAA", Path: "/media/a", Type: "movie", Enabled: true, SortOrder: 0}
second := model.Library{Name: "BBB", Path: "/media/b", Type: "movie", Enabled: true, SortOrder: 1}
third := model.Library{Name: "CCC", Path: "/media/c", Type: "movie", Enabled: true, SortOrder: 2}
for _, lib := range []*model.Library{&first, &second, &third} {
if err := svc.repo.Library.Create(t.Context(), lib); err != nil {
t.Fatalf("create library: %v", err)
}
}
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user"}
pinned, err := json.Marshal([]string{third.ID, first.ID})
if err != nil {
t.Fatal(err)
}
user.PinnedLibraryIDs = string(pinned)
if err := svc.repo.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
views, err := svc.Views(t.Context(), user.ID)
if err != nil {
t.Fatalf("Views: %v", err)
}
items := views["Items"].([]map[string]any)
if len(items) != 3 {
t.Fatalf("expected 3 views, got %d", len(items))
}
got := []string{items[0]["Id"].(string), items[1]["Id"].(string), items[2]["Id"].(string)}
want := []string{third.ID, first.ID, second.ID}
for i := range want {
if got[i] != want[i] {
t.Fatalf("Views order = %v, want %v", got, want)
}
}
}
func TestSortViewItemsByPinnedIDsKeepsUnpinnedOrder(t *testing.T) {
items := []map[string]any{
{"Id": "a", "Name": "A"},
{"Id": "b", "Name": "B"},
{"Id": "c", "Name": "C"},
{"Id": "d", "Name": "D"},
}
sorted := sortViewItemsByPinnedIDs(items, []string{"c", "a"})
got := make([]string, len(sorted))
for i, item := range sorted {
got[i] = item["Id"].(string)
}
want := []string{"c", "a", "b", "d"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
}
+74
View File
@@ -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)
}
+75
View File
@@ -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)
}
}
}
+14 -1
View File
@@ -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.
+19
View File
@@ -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
}
+63 -1
View File
@@ -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)
}
}
+35 -12
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
loadPinnedLibraryIds,
@@ -10,16 +10,32 @@ 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)
const pinnedIdsRef = useRef<string[]>([])
const syncingRef = useRef(false)
useEffect(() => {
pinnedIdsRef.current = pinnedIds
}, [pinnedIds])
useEffect(() => {
let cancelled = false
setLoading(true)
setLoadError(false)
loadedRef.current = false
loadPinnedLibraryIds()
.then((ids) => {
if (!cancelled) setPinnedIds(ids)
if (cancelled) return
loadedRef.current = true
pinnedIdsRef.current = ids
setPinnedIds(ids)
setLoadError(false)
})
.catch(() => {
if (!cancelled) setPinnedIds([])
if (cancelled) return
loadedRef.current = false
setLoadError(true)
})
.finally(() => {
if (!cancelled) setLoading(false)
@@ -30,23 +46,30 @@ export function usePinnedLibraries() {
}, [])
const togglePin = useCallback(async (libraryId: string) => {
let previous: string[] = []
let optimistic: string[] = []
setPinnedIds((current) => {
previous = current
optimistic = togglePinnedLibraryId(current, libraryId)
return optimistic
})
if (!loadedRef.current || loading || loadError || syncingRef.current) return
// Compute the next list synchronously from a ref. Do NOT capture the next
// value inside setState updater callbacks — React may defer those, leaving
// optimistic as [] and wiping the server-side pin list.
const previous = pinnedIdsRef.current
const optimistic = togglePinnedLibraryId(previous, libraryId)
pinnedIdsRef.current = optimistic
setPinnedIds(optimistic)
syncingRef.current = true
setSyncing(true)
try {
const saved = await savePinnedLibraryIds(optimistic)
pinnedIdsRef.current = saved
setPinnedIds(saved)
} catch {
pinnedIdsRef.current = previous
setPinnedIds(previous)
} finally {
syncingRef.current = false
setSyncing(false)
}
}, [])
}, [loading, loadError])
return { pinnedIds, loading, syncing, togglePin }
return { pinnedIds, loading, syncing, loadError, togglePin }
}