From e872223b4e512feec224b8dcc1e9860d6884ee50 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:26:04 +0800 Subject: [PATCH] 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 Co-authored-by: truewhile --- internal/service/emby_system.go | 49 +++++++++++++++- internal/service/emby_views_pinned_test.go | 66 ++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 internal/service/emby_views_pinned_test.go diff --git a/internal/service/emby_system.go b/internal/service/emby_system.go index 7f72328..694dbbf 100644 --- a/internal/service/emby_system.go +++ b/internal/service/emby_system.go @@ -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 { diff --git a/internal/service/emby_views_pinned_test.go b/internal/service/emby_views_pinned_test.go new file mode 100644 index 0000000..c8ef0ef --- /dev/null +++ b/internal/service/emby_views_pinned_test.go @@ -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) + } + } +}