From 1e291df647332f8d978c746514ad7be86006b494 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:47:06 +0800 Subject: [PATCH] feat: persist pinned libraries per user with cross-device sync (#20) Store pinned library IDs on the user record and expose GET/PUT /me/pinned-libraries endpoints. The web client now loads and saves pins through the API, migrates legacy localStorage data once, and applies pinned ordering on both the libraries and home pages. Co-authored-by: Cursor Agent Co-authored-by: truewhile --- internal/handler/pinned_libraries.go | 49 +++++++++ internal/handler/routes_authenticated_core.go | 2 + internal/handler/routes_authenticated_test.go | 2 + internal/model/user.go | 23 ++++ internal/service/profile.go | 101 ++++++++++++++++++ internal/service/profile_pinned_test.go | 61 +++++++++++ web/src/api/profile.ts | 6 ++ web/src/hooks/usePinnedLibraries.ts | 52 +++++++++ web/src/pages/HomePage.tsx | 11 +- web/src/pages/LibrariesPage.tsx | 11 +- web/src/types/auth.ts | 1 + web/src/utils/pinnedLibraries.ts | 63 +++++++++-- 12 files changed, 367 insertions(+), 15 deletions(-) create mode 100644 internal/handler/pinned_libraries.go create mode 100644 internal/service/profile_pinned_test.go create mode 100644 web/src/hooks/usePinnedLibraries.ts diff --git a/internal/handler/pinned_libraries.go b/internal/handler/pinned_libraries.go new file mode 100644 index 0000000..f977d46 --- /dev/null +++ b/internal/handler/pinned_libraries.go @@ -0,0 +1,49 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" +) + +type pinnedLibrariesReq struct { + LibraryIDs []string `json:"library_ids"` +} + +func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid, _ := c.Get(middleware.CtxUserID) + ids, err := svc.Profile.GetPinnedLibraryIDs(c.Request.Context(), uid.(string)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if ids == nil { + ids = []string{} + } + c.JSON(http.StatusOK, gin.H{"library_ids": ids}) + } +} + +func setPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req pinnedLibrariesReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + uid, _ := c.Get(middleware.CtxUserID) + ids, err := svc.Profile.SetPinnedLibraryIDs(c.Request.Context(), uid.(string), req.LibraryIDs) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if ids == nil { + ids = []string{} + } + c.JSON(http.StatusOK, gin.H{"library_ids": ids}) + } +} diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 88d4433..8d793c7 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -10,6 +10,8 @@ import ( func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Container) { authed.GET("/me", meHandler(svc)) authed.PATCH("/me", updateProfileHandler(svc)) + authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc)) + authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc)) authed.POST("/me/password", changePasswordHandler(svc)) authed.POST("/me/logout", logoutHandler(svc)) diff --git a/internal/handler/routes_authenticated_test.go b/internal/handler/routes_authenticated_test.go index e138d9f..b62a443 100644 --- a/internal/handler/routes_authenticated_test.go +++ b/internal/handler/routes_authenticated_test.go @@ -25,6 +25,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) { for _, want := range []string{ "GET /api/me", + "GET /api/me/pinned-libraries", + "PUT /api/me/pinned-libraries", "GET /api/auth/permissions", "GET /api/libraries", "GET /api/media", diff --git a/internal/model/user.go b/internal/model/user.go index a24861c..2149e8a 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -25,6 +25,9 @@ type User struct { // 为空时代表不限制(全库可访问)。 AllowedLibraryIDs string `gorm:"type:text" json:"-"` AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"` + // PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。 + PinnedLibraryIDs string `gorm:"type:text" json:"-"` + PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"` // ExpiredAt is the account expiry time. Nil means the account never // expires. When set and in the past, the account is treated as expired // (login blocked) until an admin or a redemption code renews it. @@ -59,10 +62,30 @@ func (u *User) DecodeAllowedLibraryIDs() []string { return out } +// DecodePinnedLibraryIDs 解析 PinnedLibraryIDs 字段。 +func (u *User) DecodePinnedLibraryIDs() []string { + if u == nil || strings.TrimSpace(u.PinnedLibraryIDs) == "" { + return nil + } + var ids []string + if err := json.Unmarshal([]byte(u.PinnedLibraryIDs), &ids); err != nil { + return nil + } + var out []string + for _, id := range ids { + trimmed := strings.TrimSpace(id) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + // PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。 func (u *User) PopulateComputedFields() { if u == nil { return } u.AllowedLibraryList = u.DecodeAllowedLibraryIDs() + u.PinnedLibraryList = u.DecodePinnedLibraryIDs() } diff --git a/internal/service/profile.go b/internal/service/profile.go index 99e8312..e2f9fa5 100644 --- a/internal/service/profile.go +++ b/internal/service/profile.go @@ -3,6 +3,7 @@ package service import ( "context" + "encoding/json" "errors" "strings" @@ -81,6 +82,106 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch return p.repo.User.FindByID(ctx, userID) } +// GetPinnedLibraryIDs returns the user's pinned library IDs, filtered to libraries +// they can still access. +func (p *ProfileService) GetPinnedLibraryIDs(ctx context.Context, userID string) ([]string, error) { + user, err := p.repo.User.FindByID(ctx, userID) + if err != nil { + return nil, err + } + if user == nil { + return nil, errors.New("user not found") + } + visibility := UserDefaultMediaVisibility(ctx, p.repo, userID) + accessible, err := p.accessibleLibraryIDSet(ctx, visibility) + if err != nil { + return nil, err + } + return filterPinnedLibraryIDs(user.DecodePinnedLibraryIDs(), accessible), nil +} + +// SetPinnedLibraryIDs persists the user's pinned library order after filtering to +// accessible, enabled libraries. +func (p *ProfileService) SetPinnedLibraryIDs(ctx context.Context, userID string, ids []string) ([]string, error) { + if userID == "" { + return nil, errors.New("missing user id") + } + user, err := p.repo.User.FindByID(ctx, userID) + if err != nil { + return nil, err + } + if user == nil { + return nil, errors.New("user not found") + } + visibility := UserDefaultMediaVisibility(ctx, p.repo, userID) + accessible, err := p.accessibleLibraryIDSet(ctx, visibility) + if err != nil { + return nil, err + } + normalized := filterPinnedLibraryIDs(normalizePinnedLibraryIDs(ids), accessible) + raw, err := json.Marshal(normalized) + if err != nil { + return nil, err + } + if err := p.repo.User.UpdateFields(ctx, userID, map[string]any{ + "pinned_library_ids": string(raw), + }); err != nil { + return nil, err + } + return normalized, nil +} + +func (p *ProfileService) accessibleLibraryIDSet(ctx context.Context, visibility MediaVisibility) (map[string]struct{}, error) { + libs, err := p.repo.Library.List(ctx) + if err != nil { + return nil, err + } + out := make(map[string]struct{}) + for _, lib := range libs { + if !lib.Enabled { + continue + } + if !LibraryVisibleForUser(ctx, p.repo, lib, visibility) { + continue + } + out[lib.ID] = struct{}{} + } + return out, nil +} + +func normalizePinnedLibraryIDs(ids []string) []string { + if len(ids) == 0 { + return nil + } + seen := make(map[string]struct{}, len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func filterPinnedLibraryIDs(ids []string, accessible map[string]struct{}) []string { + if len(ids) == 0 { + return nil + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + if _, ok := accessible[id]; ok { + out = append(out, id) + } + } + return out +} + // AdminUpdateRole lets administrators promote / demote another user. The // caller is expected to gate the route with AdminRequired. func (p *ProfileService) AdminUpdateRole(ctx context.Context, userID, role string) (*model.User, error) { diff --git a/internal/service/profile_pinned_test.go b/internal/service/profile_pinned_test.go new file mode 100644 index 0000000..ca30049 --- /dev/null +++ b/internal/service/profile_pinned_test.go @@ -0,0 +1,61 @@ +package service + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "go.uber.org/zap" +) + +func TestProfilePinnedLibrariesFiltersInaccessibleAndPreservesOrder(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{}); 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) + } + libA := &model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true} + libB := &model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true} + libHidden := &model.Library{Name: "Adult", Path: "/media/adult", Type: "movie", Enabled: true} + for _, lib := range []*model.Library{libA, libB, libHidden} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + if err := repos.User.UpdateFields(t.Context(), user.ID, map[string]any{ + "allowed_library_ids": `["` + libA.ID + `","` + libB.ID + `"]`, + }); err != nil { + t.Fatal(err) + } + + got, err := svc.SetPinnedLibraryIDs(t.Context(), user.ID, []string{ + libB.ID, libHidden.ID, libA.ID, libB.ID, "missing", + }) + if err != nil { + t.Fatalf("SetPinnedLibraryIDs: %v", err) + } + want := []string{libB.ID, libA.ID} + 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) + } +} diff --git a/web/src/api/profile.ts b/web/src/api/profile.ts index ec25fcb..f754be3 100644 --- a/web/src/api/profile.ts +++ b/web/src/api/profile.ts @@ -12,6 +12,12 @@ export const profileAPI = { }) => api.patch('/me', patch).then((r) => r.data), + getPinnedLibraries: () => + api.get<{ library_ids: string[] }>('/me/pinned-libraries').then((r) => r.data.library_ids ?? []), + + setPinnedLibraries: (libraryIds: string[]) => + api.put<{ library_ids: string[] }>('/me/pinned-libraries', { library_ids: libraryIds }).then((r) => r.data.library_ids ?? []), + adminUpdateRole: (id: string, role: 'admin' | 'user') => api.patch(`/admin/users/${id}/role`, { role }).then((r) => r.data), } diff --git a/web/src/hooks/usePinnedLibraries.ts b/web/src/hooks/usePinnedLibraries.ts new file mode 100644 index 0000000..23cc46c --- /dev/null +++ b/web/src/hooks/usePinnedLibraries.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from 'react' + +import { + loadPinnedLibraryIds, + savePinnedLibraryIds, + togglePinnedLibraryId, +} from '../utils/pinnedLibraries' + +export function usePinnedLibraries() { + const [pinnedIds, setPinnedIds] = useState([]) + const [loading, setLoading] = useState(true) + const [syncing, setSyncing] = useState(false) + + useEffect(() => { + let cancelled = false + setLoading(true) + loadPinnedLibraryIds() + .then((ids) => { + if (!cancelled) setPinnedIds(ids) + }) + .catch(() => { + if (!cancelled) setPinnedIds([]) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const togglePin = useCallback(async (libraryId: string) => { + let previous: string[] = [] + let optimistic: string[] = [] + setPinnedIds((current) => { + previous = current + optimistic = togglePinnedLibraryId(current, libraryId) + return optimistic + }) + setSyncing(true) + try { + const saved = await savePinnedLibraryIds(optimistic) + setPinnedIds(saved) + } catch { + setPinnedIds(previous) + } finally { + setSyncing(false) + } + }, []) + + return { pinnedIds, loading, syncing, togglePin } +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index e40c1cb..147ce02 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -5,6 +5,8 @@ import { historyAPI } from '../api/history' import type { HistoryItem } from '../api/playback' import type { Library, Media } from '../types' import type { SeriesCard } from '../utils/groupSeries' +import { usePinnedLibraries } from '../hooks/usePinnedLibraries' +import { sortByPinnedIds } from '../utils/pinnedLibraries' import { ContinueWatchingSection, HomeCarouselSection, @@ -22,6 +24,7 @@ export function HomePage() { const [libraryData, setLibraryData] = useState>({}) const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) + const { pinnedIds } = usePinnedLibraries() useEffect(() => { let cancelled = false @@ -126,6 +129,8 @@ export function HomePage() { return candidateMedia.slice(0, 10) }, [libraries, libraryData]) + const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds]) + const empty = !loading && libraries.length === 0 && @@ -150,9 +155,9 @@ export function HomePage() { {history.length > 0 && } {/* 3. 媒体库卡片区 */} - {libraries.length > 0 && ( + {sortedLibraries.length > 0 && ( @@ -160,7 +165,7 @@ export function HomePage() { {/* 4. 各媒体库内容展示行 */}
- {libraries.map((lib) => { + {sortedLibraries.map((lib) => { const cards = libraryData[lib.id]?.cards || [] if (cards.length === 0) return null return ( diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index f7541d5..f7dee75 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -3,17 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { libraryAPI } from '../api/library' import { toolsAPI } from '../api/tools' import { openManageLibrariesDialog } from '../components/manageLibrariesDialog' +import { usePinnedLibraries } from '../hooks/usePinnedLibraries' import { LibrariesContent, LibrariesEmptyState, LibrariesHeader, } from './LibrariesPageSections' import type { LibraryPreview } from './librariesPageModel' -import { readPinnedLibraryIds, sortLibraryPreviews, togglePinnedLibraryId } from '../utils/pinnedLibraries' +import { sortLibraryPreviews } from '../utils/pinnedLibraries' export function LibrariesPage() { const [previews, setPreviews] = useState([]) - const [pinnedIds, setPinnedIds] = useState(() => readPinnedLibraryIds()) + const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries() const [loading, setLoading] = useState(true) const [repairing, setRepairing] = useState(false) const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false) @@ -62,12 +63,12 @@ export function LibrariesPage() { const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds]) const handleTogglePin = useCallback((libraryId: string) => { - setPinnedIds(togglePinnedLibraryId(libraryId)) - }, []) + void togglePin(libraryId) + }, [togglePin]) const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews]) - if (loading) { + if (loading || pinnedLoading) { return

媒体库加载中…

} diff --git a/web/src/types/auth.ts b/web/src/types/auth.ts index 829145b..d26bc86 100644 --- a/web/src/types/auth.ts +++ b/web/src/types/auth.ts @@ -10,6 +10,7 @@ export interface User { force_password_reset: boolean is_active: boolean allowed_library_ids?: string[] + pinned_library_ids?: string[] is_default_admin?: boolean is_protected?: boolean realtime_online?: boolean diff --git a/web/src/utils/pinnedLibraries.ts b/web/src/utils/pinnedLibraries.ts index 9ecca4d..7e0d1f1 100644 --- a/web/src/utils/pinnedLibraries.ts +++ b/web/src/utils/pinnedLibraries.ts @@ -1,4 +1,7 @@ +import { profileAPI } from '../api/profile' + const STORAGE_KEY = 'mebox_pinned_libraries' +const MIGRATION_KEY = 'mebox_pinned_libraries_migrated' function parsePinnedIds(raw: string | null): string[] { if (!raw) return [] @@ -11,28 +14,74 @@ function parsePinnedIds(raw: string | null): string[] { } } -export function readPinnedLibraryIds(): string[] { +export function readLegacyPinnedLibraryIds(): string[] { if (typeof window === 'undefined') return [] return parsePinnedIds(window.localStorage.getItem(STORAGE_KEY)) } -export function writePinnedLibraryIds(ids: string[]): void { +function writeLegacyPinnedLibraryIds(ids: string[]): void { if (typeof window === 'undefined') return + if (ids.length === 0) { + window.localStorage.removeItem(STORAGE_KEY) + return + } window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)) } -export function togglePinnedLibraryId(id: string): string[] { - const current = readPinnedLibraryIds() +function markPinnedLibrariesMigrated(): void { + if (typeof window === 'undefined') return + window.localStorage.setItem(MIGRATION_KEY, '1') + window.localStorage.removeItem(STORAGE_KEY) +} + +function hasPinnedLibrariesMigrated(): boolean { + if (typeof window === 'undefined') return true + return window.localStorage.getItem(MIGRATION_KEY) === '1' +} + +export async function loadPinnedLibraryIds(): Promise { + const remote = await profileAPI.getPinnedLibraries() + if (!hasPinnedLibrariesMigrated()) { + const legacy = readLegacyPinnedLibraryIds() + if (legacy.length > 0 && remote.length === 0) { + const migrated = await profileAPI.setPinnedLibraries(legacy) + markPinnedLibrariesMigrated() + return migrated + } + markPinnedLibrariesMigrated() + } + return remote +} + +export async function savePinnedLibraryIds(ids: string[]): Promise { + const saved = await profileAPI.setPinnedLibraries(ids) + writeLegacyPinnedLibraryIds(saved) + return saved +} + +export function togglePinnedLibraryId(current: string[], id: string): string[] { const index = current.indexOf(id) - const next = index >= 0 ? current.filter((item) => item !== id) : [...current, id] - writePinnedLibraryIds(next) - return next + return index >= 0 ? current.filter((item) => item !== id) : [...current, id] } export function isLibraryPinned(id: string, pinnedIds: string[]): boolean { return pinnedIds.includes(id) } +export function sortByPinnedIds(items: T[], pinnedIds: string[]): T[] { + if (pinnedIds.length === 0) return items + const rank = new Map(pinnedIds.map((pinnedId, index) => [pinnedId, index])) + return [...items].sort((a, b) => { + const aRank = rank.get(a.id) + const bRank = rank.get(b.id) + const aPinned = aRank !== undefined + const bPinned = bRank !== undefined + if (aPinned !== bPinned) return aPinned ? -1 : 1 + if (aPinned && bPinned) return aRank - bRank + return 0 + }) +} + export function sortLibraryPreviews(items: T[], pinnedIds: string[]): T[] { if (pinnedIds.length === 0) return items const rank = new Map(pinnedIds.map((id, index) => [id, index]))