From dc44e13bfa62a4989789999e8cae5ce2b64f19ea Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:16:31 -0400 Subject: [PATCH] Filter restricted library sections and share collection queries (#190) * fix(collections): prevent collection query cache collision * Filter restricted library sections and share collection queries --- internal/api/handlers/sections.go | 61 ++++++++++++++++++ internal/api/handlers/sections_test.go | 62 +++++++++++++++++++ web/src/api/types.ts | 1 + web/src/components/AddToCollectionDialog.tsx | 24 +++---- web/src/hooks/queries/libraryCollections.ts | 24 +++++-- .../hooks/queries/useAllUserCollections.ts | 38 +++++------- 6 files changed, 170 insertions(+), 40 deletions(-) diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index 59b984d7..e2a19189 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -764,6 +764,8 @@ func (h *SectionHandler) loadResolvedHomeSections(r *http.Request) ([]sections.R } } + resolved = filterResolvedSectionsByAccess(resolved, accessFilter) + return resolved, libraryIDs, accessFilter, profileID, nil } @@ -1047,6 +1049,7 @@ func (h *SectionHandler) HandleSectionSettings(w http.ResponseWriter, r *http.Re } resolved := sections.ResolveForSettings(adminSections, overrides) + resolved = filterResolvedSectionsByAccess(resolved, requestAccessFilter(r)) type settingsEntry struct { ID string `json:"id"` @@ -1080,6 +1083,64 @@ func (h *SectionHandler) HandleSectionSettings(w http.ResponseWriter, r *http.Re writeJSON(w, http.StatusOK, map[string][]settingsEntry{"sections": entries}) } +func filterResolvedSectionsByAccess(resolved []sections.ResolvedSection, filter catalog.AccessFilter) []sections.ResolvedSection { + if filter.AllowedLibraryIDs == nil && len(filter.DisabledLibraryIDs) == 0 { + return resolved + } + + out := resolved[:0] + for _, section := range resolved { + if sectionAllowedByAccess(section, filter) { + out = append(out, section) + } + } + return out +} + +func sectionAllowedByAccess(section sections.ResolvedSection, filter catalog.AccessFilter) bool { + configLibraryIDs := sections.ParseConfigFilters(section.Config).LibraryIDs() + if len(configLibraryIDs) == 0 { + return true + } + + if filter.AllowedLibraryIDs != nil { + return intSlicesIntersect(configLibraryIDs, filter.AllowedLibraryIDs) + } + + for _, libraryID := range configLibraryIDs { + if !intSliceContains(filter.DisabledLibraryIDs, libraryID) { + return true + } + } + return false +} + +func intSlicesIntersect(left, right []int) bool { + if len(left) == 0 || len(right) == 0 { + return false + } + + set := make(map[int]struct{}, len(right)) + for _, value := range right { + set[value] = struct{}{} + } + for _, value := range left { + if _, ok := set[value]; ok { + return true + } + } + return false +} + +func intSliceContains(values []int, target int) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + // applyDiversityFilter removes items from sections whose recipe has // AvoidDuplicates=true if the same content ID was already surfaced by an // earlier section in the same render. Operates in-place — preserves order. diff --git a/internal/api/handlers/sections_test.go b/internal/api/handlers/sections_test.go index a9dbd772..925bc64f 100644 --- a/internal/api/handlers/sections_test.go +++ b/internal/api/handlers/sections_test.go @@ -390,6 +390,68 @@ func TestLibraryDefaultSectionsUsesFolderType(t *testing.T) { } } +func TestFilterResolvedSectionsByAccessHidesBlockedLibraryRows(t *testing.T) { + sectionsIn := []sections.ResolvedSection{ + { + ID: "continue", + SectionType: sections.SectionContinueWatching, + Title: "Continue Watching", + Config: sections.ContinueTypeConfig(sections.ContinueTypeWatching), + }, + { + ID: "allowed-library", + SectionType: sections.SectionRecentlyAdded, + Title: "Recently Added in Allowed", + Config: sections.GeneratedHomeLibraryRecentConfig(11), + }, + { + ID: "blocked-library", + SectionType: sections.SectionRecentlyAdded, + Title: "Recently Added in Blocked", + Config: sections.GeneratedHomeLibraryRecentConfig(42), + }, + } + + got := filterResolvedSectionsByAccess(sectionsIn, catalog.AccessFilter{ + AllowedLibraryIDs: []int{11}, + }) + + if len(got) != 2 { + t.Fatalf("filtered sections length = %d, want 2", len(got)) + } + if got[0].ID != "continue" || got[1].ID != "allowed-library" { + t.Fatalf("filtered section ids = [%s %s], want [continue allowed-library]", got[0].ID, got[1].ID) + } +} + +func TestFilterResolvedSectionsByAccessHidesDisabledOnlyRows(t *testing.T) { + sectionsIn := []sections.ResolvedSection{ + { + ID: "disabled-library", + SectionType: sections.SectionRecentlyReleased, + Title: "Recently Released in Disabled", + Config: sections.GeneratedHomeLibraryRecentConfig(42), + }, + { + ID: "mixed-libraries", + SectionType: sections.SectionRecentlyReleased, + Title: "Mixed", + Config: []byte(`{"filter_library_ids":[11,42]}`), + }, + } + + got := filterResolvedSectionsByAccess(sectionsIn, catalog.AccessFilter{ + DisabledLibraryIDs: []int{42}, + }) + + if len(got) != 1 { + t.Fatalf("filtered sections length = %d, want 1", len(got)) + } + if got[0].ID != "mixed-libraries" { + t.Fatalf("filtered section id = %s, want mixed-libraries", got[0].ID) + } +} + func TestApplyDiversityFilterSkipsDuplicatesInLaterAvoidSection(t *testing.T) { in := []sections.SectionWithItems{ {ResolvedSection: sections.ResolvedSection{ID: "ra", SectionType: sections.SectionRecentlyAdded}, Items: []*models.MediaItem{{ContentID: "abc"}}}, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6c14dc26..acd62a19 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1528,6 +1528,7 @@ export interface LibraryTabUngrouped { export interface LibraryTabResponse { library_id: number; + collections?: LibraryCollection[]; groups: LibraryTabGroup[]; ungrouped?: LibraryTabUngrouped; } diff --git a/web/src/components/AddToCollectionDialog.tsx b/web/src/components/AddToCollectionDialog.tsx index 7423ab24..6e948905 100644 --- a/web/src/components/AddToCollectionDialog.tsx +++ b/web/src/components/AddToCollectionDialog.tsx @@ -12,10 +12,11 @@ import { import { useCollections, useAddItemToCollection } from "@/hooks/queries/collections"; import { useUserLibraries } from "@/hooks/queries/libraries"; import { useQueries } from "@tanstack/react-query"; -import { api } from "@/api/client"; -import { libraryCollectionKeys } from "@/hooks/queries/keys"; +import { + getLibraryCollectionList, + libraryCollectionsQueryOptions, +} from "@/hooks/queries/libraryCollections"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; -import type { LibraryCollection } from "@/api/types"; interface AddToCollectionDialogProps { open: boolean; @@ -55,12 +56,8 @@ export default function AddToCollectionDialog({ // manual collections too. Non-admins skip these queries entirely. const libraryQueries = useQueries({ queries: (isAdmin ? (libraries ?? []) : []).map((lib) => ({ - queryKey: libraryCollectionKeys.list(lib.id), - queryFn: () => - api<{ collections: LibraryCollection[] }>(`/library/${lib.id}/collections`).then( - (data) => data.collections ?? [], - ), - enabled: Number.isFinite(lib.id) && lib.id > 0, + ...libraryCollectionsQueryOptions(lib.id), + select: getLibraryCollectionList, })), }); const libraryLoading = isAdmin && libraryQueries.some((q) => q.isLoading); @@ -76,11 +73,10 @@ export default function AddToCollectionDialog({ for (let i = 0; i < libraries.length; i++) { const lib = libraries[i]!; const res = libraryQueries[i]; - if (res?.data) { - for (const c of res.data) { - if (c.collection_type === "manual") { - out.push({ id: c.id, title: c.title, source: "library", group: lib.name }); - } + const collections = Array.isArray(res?.data) ? res.data : []; + for (const c of collections) { + if (c.collection_type === "manual") { + out.push({ id: c.id, title: c.title, source: "library", group: lib.name }); } } } diff --git a/web/src/hooks/queries/libraryCollections.ts b/web/src/hooks/queries/libraryCollections.ts index 15fef05e..d9721ec2 100644 --- a/web/src/hooks/queries/libraryCollections.ts +++ b/web/src/hooks/queries/libraryCollections.ts @@ -2,18 +2,28 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "@/api/client"; import type { BrowseItem, + LibraryCollection, LibraryTabCollection, LibraryTabResponse, ServerVisibleUserCollection, } from "@/api/types"; import { libraryCollectionKeys } from "./keys"; -export function useLibraryCollections(libraryId: number) { - return useQuery({ +export function libraryCollectionsQueryOptions(libraryId: number) { + return { queryKey: libraryCollectionKeys.list(libraryId), - queryFn: () => api(`/library/${libraryId}/collections`), + queryFn: () => + api(`/library/${libraryId}/collections`).then((data) => ({ + ...data, + collections: data.collections ?? [], + groups: data.groups ?? [], + })), enabled: Number.isFinite(libraryId) && libraryId > 0, - }); + }; +} + +export function useLibraryCollections(libraryId: number) { + return useQuery(libraryCollectionsQueryOptions(libraryId)); } export function flattenLibraryCollections( @@ -39,6 +49,12 @@ export function useLibraryUserCollections(libraryId: number) { }); } +export function getLibraryCollectionList( + resp: LibraryTabResponse | undefined, +): LibraryCollection[] { + return resp?.collections ?? []; +} + export function useLibraryCollectionItems(libraryId: number, collectionId: string | null) { return useQuery({ queryKey: libraryCollectionKeys.items(libraryId, collectionId ?? ""), diff --git a/web/src/hooks/queries/useAllUserCollections.ts b/web/src/hooks/queries/useAllUserCollections.ts index 70c5d23d..99e0495b 100644 --- a/web/src/hooks/queries/useAllUserCollections.ts +++ b/web/src/hooks/queries/useAllUserCollections.ts @@ -1,9 +1,8 @@ import { useQueries } from "@tanstack/react-query"; -import { api } from "@/api/client"; import type { LibraryCollection } from "@/api/types"; import { useUserLibraries } from "./libraries"; -import { libraryCollectionKeys } from "./keys"; import { useCollections } from "./collections"; +import { getLibraryCollectionList, libraryCollectionsQueryOptions } from "./libraryCollections"; export interface CollectionOption { id: string; @@ -23,12 +22,8 @@ export function useAllUserCollections() { const libraryQueries = useQueries({ queries: (libraries ?? []).map((lib) => ({ - queryKey: libraryCollectionKeys.list(lib.id), - queryFn: () => - api<{ collections: LibraryCollection[] }>(`/library/${lib.id}/collections`).then( - (data) => data.collections ?? [], - ), - enabled: Number.isFinite(lib.id) && lib.id > 0, + ...libraryCollectionsQueryOptions(lib.id), + select: getLibraryCollectionList, })), }); @@ -53,20 +48,19 @@ export function useAllUserCollections() { for (let i = 0; i < libraries.length; i++) { const lib = libraries[i]!; const result = libraryQueries[i]; - if (result?.data) { - for (const c of result.data) { - collections.push({ - id: c.id, - title: c.title, - source: "library", - group: lib.name, - library_id: lib.id, - library_name: lib.name, - collection_type: c.collection_type, - source_config: c.source_config, - last_sync_status: c.last_sync_status, - }); - } + const libraryCollections = Array.isArray(result?.data) ? result.data : []; + for (const c of libraryCollections) { + collections.push({ + id: c.id, + title: c.title, + source: "library", + group: lib.name, + library_id: lib.id, + library_name: lib.name, + collection_type: c.collection_type, + source_config: c.source_config, + last_sync_status: c.last_sync_status, + }); } } }