From ea732e5bd744c30c112976984bb98c46ede96013 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:40:52 +0800 Subject: [PATCH] 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 Co-authored-by: truewhile --- internal/service/profile.go | 19 ++++++++ internal/service/profile_pinned_test.go | 64 ++++++++++++++++++++++++- web/src/hooks/usePinnedLibraries.ts | 25 ++++++++-- 3 files changed, 102 insertions(+), 6 deletions(-) diff --git a/internal/service/profile.go b/internal/service/profile.go index e2f9fa5..865d12b 100644 --- a/internal/service/profile.go +++ b/internal/service/profile.go @@ -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 } diff --git a/internal/service/profile_pinned_test.go b/internal/service/profile_pinned_test.go index ca30049..153e1d7 100644 --- a/internal/service/profile_pinned_test.go +++ b/internal/service/profile_pinned_test.go @@ -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) + } +} diff --git a/web/src/hooks/usePinnedLibraries.ts b/web/src/hooks/usePinnedLibraries.ts index 23cc46c..858d1a6 100644 --- a/web/src/hooks/usePinnedLibraries.ts +++ b/web/src/hooks/usePinnedLibraries.ts @@ -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([]) 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 } }