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 } }