Exclude zip folders when browsing scenes and galleries (#6740)

* Add short cuts when only getting zip/folder ids
* Don't show zip folders when viewing scenes and galleries.

Zip folders have no results for scenes and galleries, but will for images.
This commit is contained in:
WithoutPants 2026-03-24 15:03:58 +11:00 committed by GitHub
parent 2e48dbfc63
commit fd480c5a3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 24 deletions

View file

@ -7,6 +7,7 @@ import (
"sort"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/internal/build"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/logger"
@ -145,6 +146,13 @@ func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context)
return r.repository.WithReadTxn(ctx, fn)
}
// idOnly returns true if the query is only asking for the id field.
// This can be used to optimize certain queries where we don't need to load the full object if we're only getting the id.
func (r *Resolver) idOnly(ctx context.Context) bool {
fields := graphql.CollectAllFields(ctx)
return len(fields) == 1 && fields[0] == "id"
}
func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.SceneMarker.Wall(ctx, q)

View file

@ -17,15 +17,31 @@ func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (
return nil, nil
}
if r.idOnly(ctx) {
return &models.Folder{ID: *obj.ParentFolderID}, nil
}
return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)
}
func foldersFromIDs(ids []models.FolderID) []*models.Folder {
ret := make([]*models.Folder, len(ids))
for i, id := range ids {
ret[i] = &models.Folder{ID: id}
}
return ret
}
func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) {
ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID)
if err != nil {
return nil, err
}
if r.idOnly(ctx) {
return foldersFromIDs(ids), nil
}
var errs []error
ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)
return ret, firstError(errs)
@ -37,11 +53,26 @@ func (r *folderResolver) SubFolders(ctx context.Context, obj *models.Folder) ([]
return nil, err
}
if r.idOnly(ctx) {
return foldersFromIDs(ids), nil
}
var errs []error
ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)
return ret, firstError(errs)
}
func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {
// shortcut for id only queries
if r.idOnly(ctx) {
if obj.ZipFileID == nil {
return nil, nil
}
return &BasicFile{
BaseFile: &models.BaseFile{ID: *obj.ZipFileID},
}, nil
}
return zipFileResolver(ctx, obj.ZipFileID)
}

View file

@ -1,7 +1,10 @@
query FindRootFoldersForSelect {
query FindRootFoldersForSelect($zip_file_filter: MultiCriterionInput) {
findFolders(
filter: { per_page: -1, sort: "path", direction: ASC }
folder_filter: { parent_folder: { modifier: IS_NULL } }
folder_filter: {
parent_folder: { modifier: IS_NULL }
zip_file: $zip_file_filter
}
) {
count
folders {
@ -34,6 +37,10 @@ query FindFolderHierarchyForIDs($ids: [ID!]!) {
# the parent folders will be expanded, so we need the child folders
sub_folders {
...SelectFolderData
# get zip file so we can filter out zip folders if needed
zip_file {
id
}
}
}
}

View file

@ -1,6 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
CriterionModifier,
FilterMode,
FolderDataFragment,
MultiCriterionInput,
useFindFolderHierarchyForIDsQuery,
useFindFoldersForQueryQuery,
useFindRootFoldersForSelectQuery,
@ -159,21 +162,47 @@ function mergeFolderMaps(base: IFolder[], update: IFolder[]): IFolder[] {
return ret;
}
function useFolderMap(
query: string,
skip?: boolean,
initialSelected?: string[]
) {
function useFolderMap(props: {
query: string;
skip?: boolean;
initialSelected?: string[];
mode?: FilterMode;
}) {
const { query, skip = false, initialSelected, mode } = props;
const [cachedInitialSelected] = useState<string[]>(initialSelected ?? []);
// exclude zip folders for scenes and galleries
const excludeZipFolders =
mode === FilterMode.Scenes || mode === FilterMode.Galleries;
const zipFileFilter: MultiCriterionInput | undefined = useMemo(
() =>
excludeZipFolders
? {
modifier: CriterionModifier.IsNull,
}
: undefined,
[excludeZipFolders]
);
const folderFilterForQuery = useMemo(
() => (zipFileFilter ? { zip_file: zipFileFilter } : undefined),
[zipFileFilter]
);
const { data: rootFoldersResult } = useFindRootFoldersForSelectQuery({
skip,
variables: {
zip_file_filter: zipFileFilter,
},
});
const { data: queryFoldersResult } = useFindFoldersForQueryQuery({
skip: !query,
variables: {
filter: { q: query, per_page: 200 },
folder_filter: folderFilterForQuery,
},
});
@ -213,11 +242,14 @@ function useFolderMap(
existing = {
...folder.parent_folders[i],
expanded: true,
children: folder.parent_folders[i].sub_folders.map((f) => ({
...f,
expanded: false,
children: undefined,
})),
children: folder.parent_folders[i].sub_folders
// filter out zip folders if needed
.filter((f) => f.zip_file === null || !excludeZipFolders)
.map((f) => ({
...f,
expanded: false,
children: undefined,
})),
};
ret.push(existing);
}
@ -243,11 +275,14 @@ function useFolderMap(
existing = {
...existing,
expanded: true,
children: thisFolder.sub_folders.map((f) => ({
...f,
expanded: false,
children: undefined,
})),
// filter out zip folders if needed
children: thisFolder.sub_folders
.filter((f) => f.zip_file === null || !excludeZipFolders)
.map((f) => ({
...f,
expanded: false,
children: undefined,
})),
};
currentParent!.children![existingIndex] = existing;
@ -255,7 +290,7 @@ function useFolderMap(
}
});
return ret;
}, [initialSelectedResult]);
}, [initialSelectedResult, excludeZipFolders]);
const mergedRootFolders = useMemo(() => {
if (query) {
@ -347,7 +382,10 @@ function useFolderMap(
// query children folders if not already loaded
if (folder.children === undefined) {
const subFolderResult = await queryFindSubFolders(folder.id);
const subFolderResult = await queryFindSubFolders(
folder.id,
excludeZipFolders
);
setFolderMap((current) =>
current.map(
replaceFolder({
@ -419,17 +457,19 @@ export const FolderSelector: React.FC<{
interface IInputFilterProps {
criterion: FolderCriterion;
setCriterion: (c: FolderCriterion) => void;
mode?: FilterMode;
}
export const FolderFilter: React.FC<IInputFilterProps> = ({
criterion,
setCriterion,
mode,
}) => {
const intl = useIntl();
const [query, setQuery] = useState("");
const [displayQuery, onQueryChange] = useDebouncedState(query, setQuery, 250);
const { folderMap, onToggleExpanded } = useFolderMap(query);
const { folderMap, onToggleExpanded } = useFolderMap({ query, mode });
const messages = defineMessages({
sub_folder_depth: {
@ -599,11 +639,12 @@ export const SidebarFolderFilter: React.FC<
const multipleSelected =
criterion.value.items.length > 1 || criterion.value.excluded.length > 0;
const { folderMap, onToggleExpanded } = useFolderMap(
const { folderMap, onToggleExpanded } = useFolderMap({
query,
skip,
criterion.value.items.map((i) => i.id)
);
initialSelected: criterion.value.items.map((i) => i.id),
mode: filter.mode,
});
function onSelect(folder: IFolder) {
// maintain sub-folder select if present

View file

@ -515,12 +515,15 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) =>
variables: { mode },
});
export const queryFindSubFolders = (id: string) =>
export const queryFindSubFolders = (id: string, excludeZipFolders?: boolean) =>
client.query<GQL.FindFoldersForQueryQuery>({
query: GQL.FindFoldersForQueryDocument,
variables: {
folder_filter: {
parent_folder: { value: id, modifier: GQL.CriterionModifier.Equals },
zip_file: excludeZipFolders
? { modifier: GQL.CriterionModifier.IsNull }
: undefined,
},
filter: {
per_page: -1,