Filter restricted library sections and share collection queries (#190)

* fix(collections): prevent collection query cache collision

* Filter restricted library sections and share collection queries
This commit is contained in:
Quick
2026-06-23 14:16:31 -04:00
committed by GitHub
parent 0726d9430e
commit dc44e13bfa
6 changed files with 170 additions and 40 deletions
+61
View File
@@ -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.
+62
View File
@@ -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"}}},
+1
View File
@@ -1528,6 +1528,7 @@ export interface LibraryTabUngrouped {
export interface LibraryTabResponse {
library_id: number;
collections?: LibraryCollection[];
groups: LibraryTabGroup[];
ungrouped?: LibraryTabUngrouped;
}
+10 -14
View File
@@ -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 });
}
}
}
+20 -4
View File
@@ -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<LibraryTabResponse>(`/library/${libraryId}/collections`),
queryFn: () =>
api<LibraryTabResponse>(`/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 ?? ""),
+16 -22
View File
@@ -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,
});
}
}
}