mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
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>
This commit is contained in:
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -125,7 +126,8 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目,外加所有启用的
|
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目,外加所有启用的
|
||||||
// 远程 Emby 挂载的媒体库(联邦聚合)。
|
// 远程 Emby 挂载的媒体库(联邦聚合)。顺序遵循用户置顶偏好:置顶库靠前,
|
||||||
|
// 未置顶保持原有 sort_order / 远程挂载顺序。
|
||||||
func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) {
|
func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) {
|
||||||
libs, err := e.repo.Library.List(ctx)
|
libs, err := e.repo.Library.List(ctx)
|
||||||
if err != nil {
|
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) {
|
for _, remote := range e.remoteViews(ctx) {
|
||||||
items = append(items, remote)
|
items = append(items, remote)
|
||||||
}
|
}
|
||||||
|
items = sortViewItemsByPinnedIDs(items, e.pinnedLibraryIDsForUser(ctx, userID))
|
||||||
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
|
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 返回全部启用挂载的远程媒体库视图(只有显式挂载的库才出现)。
|
// remoteViews 返回全部启用挂载的远程媒体库视图(只有显式挂载的库才出现)。
|
||||||
func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any {
|
func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any {
|
||||||
if e == nil || e.remote == nil {
|
if e == nil || e.remote == 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user