From fda97e7f6c207dbd4bfec277aa5269eb357ce44a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:11:21 +1100 Subject: [PATCH 01/20] Return if primary file failed to load (#6200) --- internal/manager/task_generate_screenshot.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index 77ad2be34..2f4031586 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -32,6 +32,7 @@ func (t *GenerateCoverTask) Start(ctx context.Context) { return t.Scene.LoadPrimaryFile(ctx, r.File) }); err != nil { logger.Error(err) + return } if !required { From 96b5a9448c123e0b346c76b3a7d33fc5b007c2b7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:11:42 +1100 Subject: [PATCH 02/20] Fix source.StashBoxEndpoint reference causing nil deref (#6201) --- internal/api/resolver_query_scraper.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index f0e89cd34..5875cd11e 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -201,7 +201,7 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So } // TODO - this should happen after any scene is scraped - if err := r.matchScenesRelationships(ctx, ret, *source.StashBoxEndpoint); err != nil { + if err := r.matchScenesRelationships(ctx, ret, b.Endpoint); err != nil { return nil, err } default: @@ -245,7 +245,7 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.So // just flatten the slice and pass it in flat := sliceutil.Flatten(ret) - if err := r.matchScenesRelationships(ctx, flat, *source.StashBoxEndpoint); err != nil { + if err := r.matchScenesRelationships(ctx, flat, b.Endpoint); err != nil { return nil, err } @@ -335,7 +335,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S if len(ret) > 0 { if err := r.withReadTxn(ctx, func(ctx context.Context) error { for _, studio := range ret { - if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, *source.StashBoxEndpoint); err != nil { + if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, b.Endpoint); err != nil { return err } } From 648875995c252f4f548d15ff0735e35b509fa619 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:12:00 +1100 Subject: [PATCH 03/20] Fix play random not using effective filter (#6202) --- ui/v2.5/src/components/Scenes/SceneList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 1154f384e..982a11fed 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -518,7 +518,7 @@ export const FilteredSceneList = (props: IFilteredScenes) => { const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]); - const playRandom = usePlayRandom(filter, totalCount); + const playRandom = usePlayRandom(effectiveFilter, totalCount); const playSelected = usePlaySelected(selectedIds); const playFirst = usePlayFirst(); From 1dccecc39c3634ed6d7b6f7d0ca9840fa824dff2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:12:25 +1100 Subject: [PATCH 04/20] Go to list page if deleting with empty history (#6203) --- .../components/Galleries/GalleryDetails/Gallery.tsx | 3 ++- ui/v2.5/src/components/Groups/GroupDetails/Group.tsx | 3 ++- ui/v2.5/src/components/Images/ImageDetails/Image.tsx | 3 ++- .../Performers/PerformerDetails/Performer.tsx | 3 ++- ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 3 ++- .../src/components/Studios/StudioDetails/Studio.tsx | 3 ++- ui/v2.5/src/components/Tags/TagDetails/Tag.tsx | 3 ++- ui/v2.5/src/utils/history.ts | 11 +++++++++++ 8 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 ui/v2.5/src/utils/history.ts diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 20023904b..5d7cdeb51 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -43,6 +43,7 @@ import cx from "classnames"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { ConfigurationContext } from "src/hooks/Config"; import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { gallery: GQL.GalleryDataFragment; @@ -167,7 +168,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { - history.goBack(); + goBackOrReplace(history, "/galleries"); } } diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index bd58a6682..b48f3b98c 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -43,6 +43,7 @@ import { Button, Tab, Tabs } from "react-bootstrap"; import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel"; import { GroupPerformersPanel } from "./GroupPerformersPanel"; import { Icon } from "src/components/Shared/Icon"; +import { goBackOrReplace } from "src/utils/history"; const validTabs = ["default", "scenes", "performers", "subgroups"] as const; type TabKey = (typeof validTabs)[number]; @@ -276,7 +277,7 @@ const GroupPage: React.FC = ({ group, tabKey }) => { return; } - history.goBack(); + goBackOrReplace(history, "/groups"); } function toggleEditing(value?: boolean) { diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 4ab6641d7..b19366032 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -34,6 +34,7 @@ import TextUtils from "src/utils/text"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { image: GQL.ImageDataFragment; @@ -156,7 +157,7 @@ const ImagePage: React.FC = ({ image }) => { function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { - history.goBack(); + goBackOrReplace(history, "/images"); } } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 03530c52e..ab584e90d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -47,6 +47,7 @@ import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; import { PatchComponent } from "src/patch"; import { ILightboxImage } from "src/hooks/Lightbox/types"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { performer: GQL.PerformerDataFragment; @@ -330,7 +331,7 @@ const PerformerPage: React.FC = PatchComponent( return; } - history.goBack(); + goBackOrReplace(history, "/performers"); } function toggleEditing(value?: boolean) { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index c4088654a..7d326b3cd 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -51,6 +51,7 @@ import { lazyComponent } from "src/utils/lazyComponent"; import cx from "classnames"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { PatchComponent, PatchContainerComponent } from "src/patch"; +import { goBackOrReplace } from "src/utils/history"; const SubmitStashBoxDraft = lazyComponent( () => import("src/components/Dialogs/SubmitDraft") @@ -909,7 +910,7 @@ const SceneLoader: React.FC> = ({ ) { loadScene(queueScenes[currentQueueIndex + 1].id); } else { - history.goBack(); + goBackOrReplace(history, "/scenes"); } } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 2140af340..46c10d73c 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -47,6 +47,7 @@ import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { studio: GQL.StudioDataFragment; @@ -378,7 +379,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { return; } - history.goBack(); + goBackOrReplace(history, "/studios"); } function renderDeleteAlert() { diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 7cded1934..6d6a4a660 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -49,6 +49,7 @@ import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; +import { goBackOrReplace } from "src/utils/history"; interface IProps { tag: GQL.TagDataFragment; @@ -420,7 +421,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { return; } - history.goBack(); + goBackOrReplace(history, "/tags"); } function renderDeleteAlert() { diff --git a/ui/v2.5/src/utils/history.ts b/ui/v2.5/src/utils/history.ts new file mode 100644 index 000000000..6ae7b637f --- /dev/null +++ b/ui/v2.5/src/utils/history.ts @@ -0,0 +1,11 @@ +import { useHistory } from "react-router-dom"; + +type History = ReturnType; + +export function goBackOrReplace(history: History, defaultPath: string) { + if (history.length > 1) { + history.goBack(); + } else { + history.replace(defaultPath); + } +} From d70ff551d4a9cc22d84daf4b1f352bfc4f9596f9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:12:42 +1100 Subject: [PATCH 05/20] Replace "movie" with "group" in scene is missing criterion (#6204) * Add support for "group" value in scene is-missing filter criterion * Replace movie with group in scene is missing criterion --- pkg/sqlite/scene_filter.go | 2 +- ui/v2.5/src/models/list-filter/criteria/is-missing.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 2e63dad97..86432a4af 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -319,7 +319,7 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite f.addWhere("galleries_join.scene_id IS NULL") case "studio": f.addWhere("scenes.studio_id IS NULL") - case "movie": + case "movie", "group": sceneRepository.groups.join(f, "groups_join", "scenes.id") f.addWhere("groups_join.scene_id IS NULL") case "performers": diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index f7387e558..58e3535a6 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -32,7 +32,7 @@ export const SceneIsMissingCriterionOption = new IsMissingCriterionOption( "date", "galleries", "studio", - "movie", + "group", "performers", "tags", "stash_id", From 9b8300e88203092e96e89e57b0187d44966fb708 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:12:57 +1100 Subject: [PATCH 06/20] Only scroll edit filter dialog when clicking filter tag (#6205) --- ui/v2.5/src/components/List/EditFilterDialog.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/List/EditFilterDialog.tsx b/ui/v2.5/src/components/List/EditFilterDialog.tsx index e914b194e..5f6d43004 100644 --- a/ui/v2.5/src/components/List/EditFilterDialog.tsx +++ b/ui/v2.5/src/components/List/EditFilterDialog.tsx @@ -50,6 +50,7 @@ interface ICriterionList { optionSelected: (o?: CriterionOption) => void; onRemoveCriterion: (c: string) => void; onTogglePin: (c: CriterionOption) => void; + externallySelected?: boolean; } const CriterionOptionList: React.FC = ({ @@ -62,6 +63,7 @@ const CriterionOptionList: React.FC = ({ optionSelected, onRemoveCriterion, onTogglePin, + externallySelected = false, }) => { const prevCriterion = usePrevious(currentCriterion); @@ -101,14 +103,19 @@ const CriterionOptionList: React.FC = ({ // scrolling to the current criterion doesn't work well when the // dialog is already open, so limit to when we click on the // criterion from the external tags - if (!scrolled.current && type && criteriaRefs[type]?.current) { + if ( + externallySelected && + !scrolled.current && + type && + criteriaRefs[type]?.current + ) { criteriaRefs[type].current!.scrollIntoView({ behavior: "smooth", block: "start", }); scrolled.current = true; } - }, [currentCriterion, criteriaRefs, type]); + }, [externallySelected, currentCriterion, criteriaRefs, type]); function getReleventCriterion(t: CriterionType) { if (currentCriterion?.criterionOption.type === t) { @@ -549,6 +556,7 @@ export const EditFilterDialog: React.FC = ({ selected={criterion?.criterionOption} onRemoveCriterion={(c) => removeCriterionString(c)} onTogglePin={(c) => onTogglePinFilter(c)} + externallySelected={!!editingCriterion} /> {criteria.length > 0 && (
From 90baa31ee35b5fdcc2fcb1850cec51cba74efcb0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:13:13 +1100 Subject: [PATCH 07/20] Hide zoom slider in xs viewports (#6206) The zoom slider doesn't function in this viewport so it shouldn't be shown. --- ui/v2.5/src/components/List/styles.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 7b02fd509..c42c43a56 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -91,6 +91,13 @@ } } +// hide zoom slider in xs viewport +@include media-breakpoint-down(xs) { + .display-mode-menu .zoom-slider-container { + display: none; + } +} + .display-mode-popover { padding-left: 0; padding-right: 0; From db79cf9bb130f8215879a0be030275d06d21eacb Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:13:29 +1100 Subject: [PATCH 08/20] Increase number of pages in pagination dropdown to 1000 (#6207) --- ui/v2.5/src/components/List/Pagination.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/List/Pagination.tsx b/ui/v2.5/src/components/List/Pagination.tsx index e117b532e..bfa6697ee 100644 --- a/ui/v2.5/src/components/List/Pagination.tsx +++ b/ui/v2.5/src/components/List/Pagination.tsx @@ -44,7 +44,7 @@ const PageCount: React.FC<{ useStopWheelScroll(pageInput); const pageOptions = useMemo(() => { - const maxPagesToShow = 10; + const maxPagesToShow = 1000; const min = Math.max(1, currentPage - maxPagesToShow / 2); const max = Math.min(min + maxPagesToShow, totalPages); const pages = []; From f04be76224abe1225f149ee8641d9520ae748273 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:13:46 +1100 Subject: [PATCH 09/20] Don't trim query string from decoded URL params (#6211) --- ui/v2.5/src/models/list-filter/filter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 2a68cd6a2..4780f1ab6 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -183,7 +183,7 @@ export class ListFilterModel { ret.disp = Number.parseInt(params.disp, 10); } if (params.q) { - ret.q = params.q.trim(); + ret.q = params.q; } if (params.p) { ret.p = Number.parseInt(params.p, 10); From fb7bd89834577d8601f235080a7c450615bf1b97 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:33:20 +1100 Subject: [PATCH 10/20] Fix update loop in Group Sub Groups panel (#6212) * Fix location equality testing causing update loop * Move defaultFilter out of component * Fix add sub groups dialog dropdown render issue --- .../Groups/GroupDetails/AddGroupsDialog.tsx | 1 + .../GroupDetails/GroupSubGroupsPanel.tsx | 26 +++++++++---------- ui/v2.5/src/components/List/util.ts | 9 ++++++- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx b/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx index b89356810..79c6075c0 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx @@ -114,6 +114,7 @@ export const AddSubGroupsDialog: React.FC = ( onUpdate={(input) => setEntries(input)} excludeIDs={excludeIDs} filterHook={filterHook} + menuPortalTarget={document.body} /> diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx index a2bb26e95..a02cb6108 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GroupList } from "../GroupList"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -101,6 +101,18 @@ interface IGroupSubGroupsPanel { group: GQL.GroupDataFragment; } +const defaultFilter = (() => { + const sortBy = "sub_group_order"; + const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, { + defaultSortBy: sortBy, + }); + + // unset the sort by so that its not included in the URL + ret.sortBy = undefined; + + return ret; +})(); + export const GroupSubGroupsPanel: React.FC = ({ active, group, @@ -114,18 +126,6 @@ export const GroupSubGroupsPanel: React.FC = ({ const filterHook = useContainingGroupFilterHook(group); - const defaultFilter = useMemo(() => { - const sortBy = "sub_group_order"; - const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, { - defaultSortBy: sortBy, - }); - - // unset the sort by so that its not included in the URL - ret.sortBy = undefined; - - return ret; - }, []); - async function removeSubGroups( result: GQL.FindGroupsQueryResult, filter: ListFilterModel, diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index c52fcc8d3..b9cb125f4 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -12,6 +12,13 @@ import * as GQL from "src/core/generated-graphql"; import { DisplayMode } from "src/models/list-filter/types"; import { Criterion } from "src/models/list-filter/criteria/criterion"; +function locationEquals( + loc1: ReturnType | undefined, + loc2: ReturnType +) { + return loc1 && loc1.pathname === loc2.pathname && loc1.search === loc2.search; +} + export function useFilterURL( filter: ListFilterModel, setFilter: React.Dispatch>, @@ -49,7 +56,7 @@ export function useFilterURL( useEffect(() => { // don't apply if active is false // also don't apply if location is unchanged - if (!active || prevLocation === location) return; + if (!active || locationEquals(prevLocation, location)) return; // re-init to load default filter on empty new query params if (!location.search) { From 299e1ac1f9f5226ae6788a7ddcba968be7743cbf Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:29:01 +1100 Subject: [PATCH 11/20] Scene list toolbar style update (#6215) * Add saved filter button to toolbar * Rearrange and add portal target * Only overlap sidebar on sm viewports * Hide dropdown button on smaller viewports when sidebar open * Center operations during selection * Restyle results header * Add classname for sidebar pane content * Move sidebar toggle to left during scene selection --- .../components/List/ListOperationButtons.tsx | 14 +- .../src/components/List/ListResultsHeader.tsx | 19 +-- ui/v2.5/src/components/List/ListToolbar.tsx | 65 +++++--- .../src/components/List/SavedFilterList.tsx | 18 ++- ui/v2.5/src/components/List/styles.scss | 140 +++++++++++++++--- ui/v2.5/src/components/Scenes/SceneList.tsx | 44 ++++-- ui/v2.5/src/components/Shared/Sidebar.tsx | 9 +- ui/v2.5/src/components/Shared/styles.scss | 8 +- 8 files changed, 235 insertions(+), 82 deletions(-) diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 6bb31339a..bdb87fa3f 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -16,22 +16,28 @@ import { faTrash, } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; +import { createPortal } from "react-dom"; export const OperationDropdown: React.FC< PropsWithChildren<{ className?: string; + menuPortalTarget?: HTMLElement; }> -> = ({ className, children }) => { +> = ({ className, menuPortalTarget, children }) => { if (!children) return null; + const menu = ( + + {children} + + ); + return ( - - {children} - + {menuPortalTarget ? createPortal(menu, menuPortalTarget) : menu} ); }; diff --git a/ui/v2.5/src/components/List/ListResultsHeader.tsx b/ui/v2.5/src/components/List/ListResultsHeader.tsx index 091317ec8..a2583c2e1 100644 --- a/ui/v2.5/src/components/List/ListResultsHeader.tsx +++ b/ui/v2.5/src/components/List/ListResultsHeader.tsx @@ -23,15 +23,6 @@ export const ListResultsHeader: React.FC<{ }) => { return ( -
- -
onChangeFilter(filter.setZoom(zoom))} />
+
+ +
+
); }; diff --git a/ui/v2.5/src/components/List/ListToolbar.tsx b/ui/v2.5/src/components/List/ListToolbar.tsx index 31ef7f7ee..25ee281c0 100644 --- a/ui/v2.5/src/components/List/ListToolbar.tsx +++ b/ui/v2.5/src/components/List/ListToolbar.tsx @@ -4,13 +4,15 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { FilterTags } from "../List/FilterTags"; import cx from "classnames"; -import { Button, ButtonToolbar } from "react-bootstrap"; +import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; import { FilterButton } from "../List/Filters/FilterButton"; import { Icon } from "../Shared/Icon"; import { SearchTermInput } from "../List/ListFilter"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { SidebarToggleButton } from "../Shared/Sidebar"; import { PatchComponent } from "src/patch"; +import { SavedFilterDropdown } from "./SavedFilterList"; +import { View } from "./views"; export const ToolbarFilterSection: React.FC<{ filter: ListFilterModel; @@ -21,6 +23,7 @@ export const ToolbarFilterSection: React.FC<{ onRemoveAllCriterion: () => void; onEditSearchTerm: () => void; onRemoveSearchTerm: () => void; + view?: View; }> = PatchComponent( "ToolbarFilterSection", ({ @@ -32,6 +35,7 @@ export const ToolbarFilterSection: React.FC<{ onRemoveAllCriterion, onEditSearchTerm, onRemoveSearchTerm, + view, }) => { const { criteria, searchTerm } = filter; @@ -41,10 +45,19 @@ export const ToolbarFilterSection: React.FC<{
- onEditCriterion()} - count={criteria.length} - /> + + + + onEditCriterion()} + count={criteria.length} + /> + -
); @@ -65,28 +77,33 @@ export const ToolbarFilterSection: React.FC<{ export const ToolbarSelectionSection: React.FC<{ selected: number; onToggleSidebar: () => void; + operations?: React.ReactNode; onSelectAll: () => void; onSelectNone: () => void; }> = PatchComponent( "ToolbarSelectionSection", - ({ selected, onToggleSidebar, onSelectAll, onSelectNone }) => { + ({ selected, onToggleSidebar, operations, onSelectAll, onSelectNone }) => { const intl = useIntl(); return ( -
- - {selected} selected - - +
+
+ + + {selected} selected + +
+ {operations} +
); } @@ -114,7 +131,11 @@ export const FilteredListToolbar2: React.FC<{ })} > {!hasSelection ? filterSection : selectionSection} -
{operationSection}
+ {!hasSelection ? ( +
+ {operationSection} +
+ ) : null} ); }; diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index 7e03404d2..83c6d8a65 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -31,6 +31,7 @@ import { AlertModal } from "../Shared/Alert"; import cx from "classnames"; import { TruncatedInlineText } from "../Shared/TruncatedText"; import { OperationButton } from "../Shared/OperationButton"; +import { createPortal } from "react-dom"; const ExistingSavedFilterList: React.FC<{ name: string; @@ -243,6 +244,7 @@ interface ISavedFilterListProps { filter: ListFilterModel; onSetFilter: (f: ListFilterModel) => void; view?: View; + menuPortalTarget?: Element | DocumentFragment; } export const SavedFilterList: React.FC = ({ @@ -841,8 +843,15 @@ export const SavedFilterDropdown: React.FC = (props) => { )); SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; + const menu = ( + + ); + return ( - + = (props) => { - + {props.menuPortalTarget + ? createPortal(menu, props.menuPortalTarget) + : menu} ); }; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index c42c43a56..aba1f39df 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -1055,7 +1055,7 @@ input[type="range"].zoom-slider { } // hide sidebar Edit Filter button on larger screens -@include media-breakpoint-up(lg) { +@include media-breakpoint-up(md) { .sidebar .edit-filter-button { display: none; } @@ -1071,6 +1071,7 @@ input[type="range"].zoom-slider { display: flex; flex-wrap: wrap; justify-content: space-between; + margin-bottom: 0; row-gap: 1rem; > div { @@ -1101,10 +1102,6 @@ input[type="range"].zoom-slider { top: 0; } - .selected-items-info .btn { - margin-right: 0.5rem; - } - // hide drop down menu items for play and create new // when the buttons are visible @include media-breakpoint-up(sm) { @@ -1125,7 +1122,7 @@ input[type="range"].zoom-slider { } } - .selected-items-info, + .toolbar-selection-section, div.filter-section { border: 1px solid $secondary; border-radius: 0.25rem; @@ -1133,13 +1130,69 @@ input[type="range"].zoom-slider { overflow-x: hidden; } - .sidebar-toggle-button { - margin-left: auto; + div.toolbar-selection-section { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: center; + + .sidebar-toggle-button { + margin-right: 0.5rem; + } + + .selected-items-info { + align-items: center; + display: flex; + } + + > div:first-child, + > div:last-child { + flex: 1; + } + + > div:last-child { + display: flex; + justify-content: flex-end; + } + + .scene-list-operations { + display: flex; + } + + // on smaller viewports move the operation buttons to the right + @include media-breakpoint-down(md) { + div.scene-list-operations { + flex: 1; + justify-content: flex-end; + order: 3; + } + + > div:last-child { + flex: 0; + order: 2; + } + } + } + + // on larger viewports, move the operation buttons to the center + @include media-breakpoint-up(lg) { + div.toolbar-selection-section div.scene-list-operations { + justify-content: center; + + > .btn-group { + gap: 0.5rem; + } + } + + div.toolbar-selection-section .empty-space { + flex: 1; + order: 3; + } } .search-container { border-right: 1px solid $secondary; - display: block; + display: flex; margin-right: -0.5rem; min-width: calc($sidebar-width - 15px); padding-right: 10px; @@ -1175,21 +1228,27 @@ input[type="range"].zoom-slider { } } -@include media-breakpoint-up(xl) { +// hide the search box in the toolbar when sidebar is shown on larger screens +// larger screens don't overlap the sidebar +@include media-breakpoint-up(md) { .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .search-container { display: none; } } +// hide the search box when sidebar is hidden on smaller screens @include media-breakpoint-down(md) { .sidebar-pane.hide-sidebar .filtered-list-toolbar .search-container { display: none; } } -// hide the filter icon button when sidebar is shown on smaller screens -@include media-breakpoint-down(md) { - .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar .filter-button { - display: none; +// hide the filter and saved filters icon buttons when sidebar is shown on smaller screens +@include media-breakpoint-down(sm) { + .sidebar-pane:not(.hide-sidebar) .filtered-list-toolbar { + .filter-button, + .saved-filter-dropdown { + display: none; + } } // adjust the width of the filter-tags as well @@ -1198,8 +1257,8 @@ input[type="range"].zoom-slider { } } -// move the sidebar toggle to the left on xl viewports -@include media-breakpoint-up(xl) { +// move the sidebar toggle to the left on larger viewports +@include media-breakpoint-up(md) { .filtered-list-toolbar .filter-section { .sidebar-toggle-button { margin-left: 0; @@ -1249,14 +1308,18 @@ input[type="range"].zoom-slider { align-items: center; background-color: $body-bg; display: flex; - justify-content: space-between; > div { align-items: center; display: flex; + flex: 1; gap: 0.5rem; justify-content: flex-start; + &.pagination-index-container { + justify-content: center; + } + &:last-child { flex-shrink: 0; justify-content: flex-end; @@ -1265,18 +1328,55 @@ input[type="range"].zoom-slider { } .list-results-header { - flex-wrap: wrap-reverse; - gap: 0.5rem; + gap: 0.25rem; margin-bottom: 0.5rem; .paginationIndex { margin: 0; } + // move pagination info to right on medium screens + @include media-breakpoint-down(md) { + & > .empty-space { + flex: 0; + } + + & > div.pagination-index-container { + justify-content: flex-end; + order: 3; + } + } + // center the header on smaller screens @include media-breakpoint-down(sm) { & > div, - & > div:last-child { + & > div.pagination-index-container { + flex-basis: 100%; + justify-content: center; + margin-left: auto; + margin-right: auto; + } + } +} + +// sidebar visible styling +.sidebar-pane:not(.hide-sidebar) .list-results-header { + // move pagination info to right on medium screens when sidebar + @include media-breakpoint-down(lg) { + & > .empty-space { + flex: 0; + } + + & > div.pagination-index-container { + justify-content: flex-end; + order: 3; + } + } + + // center the header on smaller screens when sidebar is visible + @include media-breakpoint-down(md) { + & > div, + & > div.pagination-index-container { flex-basis: 100%; justify-content: center; margin-left: auto; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 982a11fed..f9257c9ad 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -37,7 +37,12 @@ import { OperationDropdownItem, } from "../List/ListOperationButtons"; import { useFilteredItemList } from "../List/ItemList"; -import { Sidebar, SidebarPane, useSidebarState } from "../Shared/Sidebar"; +import { + Sidebar, + SidebarPane, + SidebarPaneContent, + useSidebarState, +} from "../Shared/Sidebar"; import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter"; import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter"; import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers"; @@ -355,7 +360,7 @@ const SceneListOperations: React.FC<{ const intl = useIntl(); return ( -
+
{!!items && (
diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx index 52f9328f0..2fe0c48af 100644 --- a/ui/v2.5/src/components/Shared/Sidebar.tsx +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -16,7 +16,8 @@ import { useIntl } from "react-intl"; import { Icon } from "./Icon"; import { faSliders } from "@fortawesome/free-solid-svg-icons"; -const fixedSidebarMediaQuery = "only screen and (max-width: 1199px)"; +// this needs to correspond to the CSS media query that overlaps the sidebar over content +const fixedSidebarMediaQuery = "only screen and (max-width: 767px)"; export const Sidebar: React.FC< PropsWithChildren<{ @@ -56,6 +57,10 @@ export const SidebarPane: React.FC< ); }; +export const SidebarPaneContent: React.FC = ({ children }) => { + return
{children}
; +}; + export const SidebarSection: React.FC< PropsWithChildren<{ text: React.ReactNode; @@ -87,7 +92,7 @@ export const SidebarToggleButton: React.FC<{ const intl = useIntl(); return (
); diff --git a/ui/v2.5/src/components/Shared/CollapseButton.tsx b/ui/v2.5/src/components/Shared/CollapseButton.tsx index d0b192162..0d05f6e64 100644 --- a/ui/v2.5/src/components/Shared/CollapseButton.tsx +++ b/ui/v2.5/src/components/Shared/CollapseButton.tsx @@ -3,7 +3,7 @@ import { faChevronRight, faChevronUp, } from "@fortawesome/free-solid-svg-icons"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Collapse, CollapseProps } from "react-bootstrap"; import { Icon } from "./Icon"; @@ -12,22 +12,27 @@ interface IProps { text: React.ReactNode; collapseProps?: Partial; outsideCollapse?: React.ReactNode; - onOpen?: () => void; + onOpenChanged?: (o: boolean) => void; + open?: boolean; } export const CollapseButton: React.FC> = ( props: React.PropsWithChildren ) => { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(props.open ?? false); function toggleOpen() { const nv = !open; setOpen(nv); - if (props.onOpen && nv) { - props.onOpen(); - } + props.onOpenChanged?.(nv); } + useEffect(() => { + if (props.open !== undefined) { + setOpen(props.open); + } + }, [props.open]); + return (
diff --git a/ui/v2.5/src/components/Shared/Sidebar.tsx b/ui/v2.5/src/components/Shared/Sidebar.tsx index 2fe0c48af..0130fe85f 100644 --- a/ui/v2.5/src/components/Shared/Sidebar.tsx +++ b/ui/v2.5/src/components/Shared/Sidebar.tsx @@ -15,6 +15,9 @@ import { Button, CollapseProps } from "react-bootstrap"; import { useIntl } from "react-intl"; import { Icon } from "./Icon"; import { faSliders } from "@fortawesome/free-solid-svg-icons"; +import { useHistory } from "react-router-dom"; + +export type SidebarSectionStates = Record; // this needs to correspond to the CSS media query that overlaps the sidebar over content const fixedSidebarMediaQuery = "only screen and (max-width: 767px)"; @@ -61,14 +64,35 @@ export const SidebarPaneContent: React.FC = ({ children }) => { return
{children}
; }; +interface IContext { + sectionOpen: SidebarSectionStates; + setSectionOpen: (section: string, open: boolean) => void; +} + +export const SidebarStateContext = React.createContext(null); + export const SidebarSection: React.FC< PropsWithChildren<{ text: React.ReactNode; className?: string; outsideCollapse?: React.ReactNode; - onOpen?: () => void; + // used to store open/closed state in SidebarStateContext + sectionID?: string; }> -> = ({ className = "", text, outsideCollapse, onOpen, children }) => { +> = ({ className = "", text, outsideCollapse, sectionID = "", children }) => { + // this is optional + const contextState = React.useContext(SidebarStateContext); + const openState = + !contextState || !sectionID + ? undefined + : contextState.sectionOpen[sectionID] ?? undefined; + + function onOpenInternal(open: boolean) { + if (contextState && sectionID) { + contextState.setSectionOpen(sectionID, open); + } + } + const collapseProps: Partial = { mountOnEnter: true, unmountOnExit: true, @@ -79,7 +103,8 @@ export const SidebarSection: React.FC< collapseProps={collapseProps} text={text} outsideCollapse={outsideCollapse} - onOpen={onOpen} + onOpenChanged={onOpenInternal} + open={openState} > {children} @@ -110,6 +135,7 @@ export function defaultShowSidebar() { export function useSidebarState(view?: View) { const [interfaceLocalForage, setInterfaceLocalForage] = useInterfaceLocalForage(); + const history = useHistory(); const { data: interfaceLocalForageData, loading } = interfaceLocalForage; @@ -118,6 +144,7 @@ export function useSidebarState(view?: View) { }, [view, interfaceLocalForageData]); const [showSidebar, setShowSidebar] = useState(); + const [sectionOpen, setSectionOpen] = useState(); // set initial state once loading is done useEffect(() => { @@ -132,7 +159,17 @@ export function useSidebarState(view?: View) { // only show sidebar by default on large screens setShowSidebar(!!viewConfig.showSidebar && defaultShowSidebar()); - }, [view, loading, showSidebar, viewConfig.showSidebar]); + setSectionOpen( + (history.location.state as { sectionOpen?: SidebarSectionStates }) + ?.sectionOpen || {} + ); + }, [ + view, + loading, + showSidebar, + viewConfig.showSidebar, + history.location.state, + ]); const onSetShowSidebar = useCallback( (show: boolean | ((prevState: boolean | undefined) => boolean)) => { @@ -154,9 +191,28 @@ export function useSidebarState(view?: View) { [showSidebar, setInterfaceLocalForage, view, viewConfig] ); + const onSetSectionOpen = useCallback( + (section: string, open: boolean) => { + const newSectionOpen = { ...sectionOpen, [section]: open }; + setSectionOpen(newSectionOpen); + if (view === undefined) return; + + history.replace({ + ...history.location, + state: { + ...(history.location.state as {}), + sectionOpen: newSectionOpen, + }, + }); + }, + [sectionOpen, view, history] + ); + return { showSidebar: showSidebar ?? defaultShowSidebar(), + sectionOpen: sectionOpen || {}, setShowSidebar: onSetShowSidebar, + setSectionOpen: onSetSectionOpen, loading: showSidebar === undefined, }; } From 1b2b4c52218b25f053e6818fe6f089ffdd50e34e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:54:35 +1100 Subject: [PATCH 13/20] Fix panic when scraping with unknown field (#6220) * Fix URL in group scraper causing panic * Return error instead of panicking on unknown field --- pkg/models/model_scraped_item.go | 1 + pkg/scraper/mapped.go | 85 +++++++++++++++++--------------- pkg/scraper/postprocessing.go | 30 +++++++++++ 3 files changed, 76 insertions(+), 40 deletions(-) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 008a05c3d..f7a9d6255 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -462,6 +462,7 @@ type ScrapedGroup struct { Date *string `json:"date"` Rating *string `json:"rating"` Director *string `json:"director"` + URL *string `json:"url"` // included for backward compatibility URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 4b2559334..dcd1af1dd 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -873,50 +873,55 @@ func (r mappedResult) apply(dest interface{}) { func mapFieldValue(destVal reflect.Value, key string, value interface{}) error { field := destVal.FieldByName(key) + + if !field.IsValid() { + return fmt.Errorf("field %s does not exist on %s", key, destVal.Type().Name()) + } + + if !field.CanSet() { + return fmt.Errorf("field %s cannot be set on %s", key, destVal.Type().Name()) + } + fieldType := field.Type() - if field.IsValid() && field.CanSet() { - switch v := value.(type) { - case string: - // if the field is a pointer to a string, then we need to convert the string to a pointer - // if the field is a string slice, then we need to convert the string to a slice - switch { - case fieldType.Kind() == reflect.String: - field.SetString(v) - case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String: - ptr := reflect.New(fieldType.Elem()) - ptr.Elem().SetString(v) - field.Set(ptr) - case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String: - field.Set(reflect.ValueOf([]string{v})) - default: - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } - case []string: - // expect the field to be a string slice - if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String { - field.Set(reflect.ValueOf(v)) - } else { - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } + switch v := value.(type) { + case string: + // if the field is a pointer to a string, then we need to convert the string to a pointer + // if the field is a string slice, then we need to convert the string to a slice + switch { + case fieldType.Kind() == reflect.String: + field.SetString(v) + case fieldType.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.String: + ptr := reflect.New(fieldType.Elem()) + ptr.Elem().SetString(v) + field.Set(ptr) + case fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String: + field.Set(reflect.ValueOf([]string{v})) default: - // fallback to reflection - reflectValue := reflect.ValueOf(value) - reflectValueType := reflectValue.Type() - - switch { - case reflectValueType.ConvertibleTo(fieldType): - field.Set(reflectValue.Convert(fieldType)) - case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()): - ptr := reflect.New(fieldType.Elem()) - ptr.Elem().Set(reflectValue.Convert(fieldType.Elem())) - field.Set(ptr) - default: - return fmt.Errorf("cannot convert %T to %s", value, fieldType) - } + return fmt.Errorf("cannot convert %T to %s", value, fieldType) + } + case []string: + // expect the field to be a string slice + if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.String { + field.Set(reflect.ValueOf(v)) + } else { + return fmt.Errorf("cannot convert %T to %s", value, fieldType) + } + default: + // fallback to reflection + reflectValue := reflect.ValueOf(value) + reflectValueType := reflectValue.Type() + + switch { + case reflectValueType.ConvertibleTo(fieldType): + field.Set(reflectValue.Convert(fieldType)) + case fieldType.Kind() == reflect.Pointer && reflectValueType.ConvertibleTo(fieldType.Elem()): + ptr := reflect.New(fieldType.Elem()) + ptr.Elem().Set(reflectValue.Convert(fieldType.Elem())) + field.Set(ptr) + default: + return fmt.Errorf("cannot convert %T to %s", value, fieldType) } - } else { - return fmt.Errorf("field does not exist or cannot be set") } return nil diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index e12c1664f..62aa53c72 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -143,6 +143,21 @@ func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, exclu return nil, nil, err } + // populate URL/URLs + // if URLs are provided, only use those + if len(m.URLs) > 0 { + m.URL = &m.URLs[0] + } else { + urls := []string{} + if m.URL != nil { + urls = append(urls, *m.URL) + } + + if len(urls) > 0 { + m.URLs = urls + } + } + // post-process - set the image if applicable if err := setMovieFrontImage(ctx, c.client, &m, c.globalConfig); err != nil { logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err) @@ -175,6 +190,21 @@ func (c Cache) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, exclu return nil, nil, err } + // populate URL/URLs + // if URLs are provided, only use those + if len(m.URLs) > 0 { + m.URL = &m.URLs[0] + } else { + urls := []string{} + if m.URL != nil { + urls = append(urls, *m.URL) + } + + if len(urls) > 0 { + m.URLs = urls + } + } + // post-process - set the image if applicable if err := setGroupFrontImage(ctx, c.client, &m, c.globalConfig); err != nil { logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err) From fa2fd31ac7c30af8f6e1600111c274a7c1264abe Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:24:33 +0200 Subject: [PATCH 14/20] Update library section in Configuration.md for clarity (#6232) --- ui/v2.5/src/docs/en/Manual/Configuration.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index 9b0469114..d7c1b4804 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -2,7 +2,13 @@ ## Library -This section allows you to add and remove directories from your library list. Files in these directories will be included when scanning. Files that are outside of these directories will be removed when running the Clean task. +This section enables you to add or remove directories that will be discoverable by Stash. The directories you add will be utilized for scanning new files and for updating their locations in Stash database. + +You can configure these directories to apply specifically to: + +- **Videos** +- **Images** +- **Both** > **⚠️ Note:** Don't forget to click `Save` after updating these directories! From 6cace4ff8826db3ea6c663fb186f24ba13c3700a Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:53:43 -0800 Subject: [PATCH 15/20] Update parser to accept groups (#6228) --- pkg/scraper/mapped.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index dcd1af1dd..f89499176 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -126,6 +126,7 @@ type mappedSceneScraperConfig struct { Performers mappedPerformerScraperConfig `yaml:"Performers"` Studio mappedConfig `yaml:"Studio"` Movies mappedConfig `yaml:"Movies"` + Groups mappedConfig `yaml:"Groups"` } type _mappedSceneScraperConfig mappedSceneScraperConfig @@ -134,6 +135,7 @@ const ( mappedScraperConfigScenePerformers = "Performers" mappedScraperConfigSceneStudio = "Studio" mappedScraperConfigSceneMovies = "Movies" + mappedScraperConfigSceneGroups = "Groups" ) func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -151,11 +153,13 @@ func (s *mappedSceneScraperConfig) UnmarshalYAML(unmarshal func(interface{}) err thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers] thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio] thisMap[mappedScraperConfigSceneMovies] = parentMap[mappedScraperConfigSceneMovies] + thisMap[mappedScraperConfigSceneGroups] = parentMap[mappedScraperConfigSceneGroups] delete(parentMap, mappedScraperConfigSceneTags) delete(parentMap, mappedScraperConfigScenePerformers) delete(parentMap, mappedScraperConfigSceneStudio) delete(parentMap, mappedScraperConfigSceneMovies) + delete(parentMap, mappedScraperConfigSceneGroups) // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) @@ -1013,6 +1017,7 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu sceneTagsMap := sceneScraperConfig.Tags sceneStudioMap := sceneScraperConfig.Studio sceneMoviesMap := sceneScraperConfig.Movies + sceneGroupsMap := sceneScraperConfig.Groups ret.Performers = s.processPerformers(ctx, scenePerformersMap, q) @@ -1039,7 +1044,12 @@ func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQu ret.Movies = processRelationships[models.ScrapedMovie](ctx, s, sceneMoviesMap, q) } - return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 + if sceneGroupsMap != nil { + logger.Debug(`Processing scene groups:`) + ret.Groups = processRelationships[models.ScrapedGroup](ctx, s, sceneGroupsMap, q) + } + + return len(ret.Performers) > 0 || len(ret.Tags) > 0 || ret.Studio != nil || len(ret.Movies) > 0 || len(ret.Groups) > 0 } func (s mappedScraper) processPerformers(ctx context.Context, performersMap mappedPerformerScraperConfig, q mappedQuery) []*models.ScrapedPerformer { From f2a787a2bab8cc64c874f59f8cef94afcffb508a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:45:57 +1100 Subject: [PATCH 16/20] Add (hidden) pagination to list results header (#6234) --- .../src/components/List/ListResultsHeader.tsx | 8 +++++++- ui/v2.5/src/components/List/styles.scss | 20 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/List/ListResultsHeader.tsx b/ui/v2.5/src/components/List/ListResultsHeader.tsx index a2583c2e1..8a1bba05e 100644 --- a/ui/v2.5/src/components/List/ListResultsHeader.tsx +++ b/ui/v2.5/src/components/List/ListResultsHeader.tsx @@ -1,6 +1,6 @@ import React from "react"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { PaginationIndex } from "../List/Pagination"; +import { Pagination, PaginationIndex } from "../List/Pagination"; import { ButtonToolbar } from "react-bootstrap"; import { ListViewOptions } from "../List/ListViewOptions"; import { PageSizeSelector, SortBySelect } from "../List/ListFilter"; @@ -53,6 +53,12 @@ export const ListResultsHeader: React.FC<{ />
+ onChangeFilter(filter.changePage(page))} + />