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:
@@ -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.
|
||||
|
||||
@@ -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"}}},
|
||||
|
||||
@@ -1528,6 +1528,7 @@ export interface LibraryTabUngrouped {
|
||||
|
||||
export interface LibraryTabResponse {
|
||||
library_id: number;
|
||||
collections?: LibraryCollection[];
|
||||
groups: LibraryTabGroup[];
|
||||
ungrouped?: LibraryTabUngrouped;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? ""),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user