From 877491e62bb6efd405345dbdb9acf6a8a4473717 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:09:49 -0600 Subject: [PATCH] Manually Search Stash ID - Edit Page - Scenes, Studios (#6340) --- internal/api/resolver_query_scraper.go | 2 +- pkg/stashbox/graphql/generated_client.go | 42 ++++ .../PerformerDetails/PerformerEditPanel.tsx | 22 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 39 +++- .../Shared/StashBoxIDSearchModal.tsx | 197 ++++++++++++++++-- .../Studios/StudioDetails/StudioEditPanel.tsx | 50 ++++- ui/v2.5/src/locales/en-GB.json | 1 + ui/v2.5/src/utils/stashIds.ts | 24 +++ 8 files changed, 339 insertions(+), 38 deletions(-) diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 5875cd11e..4d77f227d 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -350,7 +350,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S return nil, nil } - return nil, errors.New("stash_box_index must be set") + return nil, errors.New("stash_box_endpoint must be set") } func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) { diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index 90553b14a..e2a18352e 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -17,6 +17,7 @@ type StashBoxGraphQLClient interface { FindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error) FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) + FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) @@ -763,6 +764,17 @@ func (t *FindStudio) GetFindStudio() *StudioFragment { return t.FindStudio } +type FindTag struct { + FindTag *TagFragment "json:\"findTag,omitempty\" graphql:\"findTag\"" +} + +func (t *FindTag) GetFindTag() *TagFragment { + if t == nil { + t = &FindTag{} + } + return t.FindTag +} + type SubmitFingerprint struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } @@ -1695,6 +1707,35 @@ func (c *Client) FindStudio(ctx context.Context, id *string, name *string, inter return &res, nil } +const FindTagDocument = `query FindTag ($id: ID, $name: String) { + findTag(id: $id, name: $name) { + ... TagFragment + } +} +fragment TagFragment on Tag { + name + id +} +` + +func (c *Client) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) { + vars := map[string]any{ + "id": id, + "name": name, + } + + var res FindTag + if err := c.Client.Post(ctx, "FindTag", FindTagDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) { submitFingerprint(input: $input) } @@ -1796,6 +1837,7 @@ var DocumentOperationNames = map[string]string{ FindPerformerByIDDocument: "FindPerformerByID", FindSceneByIDDocument: "FindSceneByID", FindStudioDocument: "FindStudio", + FindTagDocument: "FindTag", SubmitFingerprintDocument: "SubmitFingerprint", MeDocument: "Me", SubmitSceneDraftDocument: "SubmitSceneDraft", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 55bd20910..8d1352da0 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -15,7 +15,7 @@ import { ImageInput } from "src/components/Shared/ImageInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { CountrySelect } from "src/components/Shared/CountrySelect"; import ImageUtils from "src/utils/image"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { stashboxDisplayName } from "src/utils/stashbox"; import { useToast } from "src/hooks/Toast"; import { Prompt } from "react-router-dom"; @@ -574,23 +574,10 @@ export const PerformerEditPanel: React.FC = ({ function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; - - // Check if StashID with this endpoint already exists - const existingIndex = formik.values.stash_ids.findIndex( - (s) => s.endpoint === item.endpoint + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) ); - - let newStashIDs; - if (existingIndex >= 0) { - // Replace existing StashID - newStashIDs = [...formik.values.stash_ids]; - newStashIDs[existingIndex] = item; - } else { - // Add new StashID - newStashIDs = [...formik.values.stash_ids, item]; - } - - formik.setFieldValue("stash_ids", newStashIDs); } function renderButtons(classNames: string) { @@ -685,6 +672,7 @@ export const PerformerEditPanel: React.FC = ({ {maybeRenderScrapeDialog()} {isStashIDSearchOpen && ( s.endpoint diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index e56ea265b..11575ea7b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -16,12 +16,12 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ImageInput } from "src/components/Shared/ImageInput"; import { useToast } from "src/hooks/Toast"; import ImageUtils from "src/utils/image"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { useConfigurationContext } from "src/hooks/Config"; import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; -import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { faSearch, faPlus } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { lazyComponent } from "src/utils/lazyComponent"; @@ -41,6 +41,7 @@ import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -77,6 +78,8 @@ export const SceneEditPanel: React.FC = ({ const [scraper, setScraper] = useState(); const [isScraperQueryModalOpen, setIsScraperQueryModalOpen] = useState(false); + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = + useState(false); const [scrapedScene, setScrapedScene] = useState(); const [endpoint, setEndpoint] = useState(); @@ -547,6 +550,14 @@ export const SceneEditPanel: React.FC = ({ } } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) + ); + } + const image = useMemo(() => { if (encodingImage) { return ( @@ -696,6 +707,19 @@ export const SceneEditPanel: React.FC = ({ {renderScrapeQueryModal()} {maybeRenderScrapeDialog()} + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + /> + )}
@@ -761,7 +785,16 @@ export const SceneEditPanel: React.FC = ({ "stash_ids", "scenes", "stash_ids", - fullWidthProps + fullWidthProps, + )} diff --git a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx index 0b11a6d25..790e6aed9 100644 --- a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx +++ b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx @@ -11,11 +11,23 @@ import TextUtils from "src/utils/text"; import GenderIcon from "src/components/Performers/GenderIcon"; import { CountryFlag } from "src/components/Shared/CountryFlag"; import { Icon } from "src/components/Shared/Icon"; -import { stashBoxPerformerQuery } from "src/core/StashService"; +import { + stashBoxPerformerQuery, + stashBoxSceneQuery, + stashBoxStudioQuery, +} from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { stringToGender } from "src/utils/gender"; +type SearchResultItem = + | GQL.ScrapedPerformerDataFragment + | GQL.ScrapedSceneDataFragment + | GQL.ScrapedStudioDataFragment; + +export type StashBoxEntityType = "performer" | "scene" | "studio"; + interface IProps { + entityType: StashBoxEntityType; stashBoxes: GQL.StashBox[]; excludedStashBoxEndpoints?: string[]; onSelectItem: (item?: GQL.StashIdInput) => void; @@ -132,8 +144,121 @@ export const PerformerSearchResult: React.FC = ({ ); }; +// Scene Result Component +interface ISceneResultProps { + scene: GQL.ScrapedSceneDataFragment; +} + +const SceneSearchResultDetails: React.FC = ({ scene }) => { + return ( +
+ + +
+

+ {scene.title} + {scene.code && ( + {` (${scene.code})`} + )} +

+
+ {scene.studio?.name && {scene.studio.name}} + {scene.date && ( + {` • ${scene.date}`} + )} +
+ {scene.performers && scene.performers.length > 0 && ( +
+ {scene.performers.map((p) => p.name).join(", ")} +
+ )} +
+
+ + + + + + +
+ ); +}; + +export const SceneSearchResult: React.FC = ({ scene }) => { + return ( +
+ +
+ ); +}; + +// Studio Result Component +interface IStudioResultProps { + studio: GQL.ScrapedStudioDataFragment; +} + +const StudioSearchResultDetails: React.FC = ({ + studio, +}) => { + return ( +
+ + +
+

+ {studio.name} +

+ {studio.parent?.name && ( +
+ {studio.parent.name} +
+ )} + {studio.urls && studio.urls.length > 0 && ( +
{studio.urls[0]}
+ )} +
+
+
+ ); +}; + +export const StudioSearchResult: React.FC = ({ + studio, +}) => { + return ( +
+ +
+ ); +}; + +// Helper to get entity type message id for i18n +function getEntityTypeMessageId(entityType: StashBoxEntityType): string { + switch (entityType) { + case "performer": + return "performer"; + case "scene": + return "scene"; + case "studio": + return "studio"; + } +} + +// Helper to get the "found" message id based on entity type +function getFoundMessageId(entityType: StashBoxEntityType): string { + switch (entityType) { + case "performer": + return "dialogs.performers_found"; + case "scene": + return "dialogs.scenes_found"; + case "studio": + return "dialogs.studios_found"; + } +} + // Main Modal Component export const StashBoxIDSearchModal: React.FC = ({ + entityType, stashBoxes, excludedStashBoxEndpoints = [], onSelectItem, @@ -146,9 +271,9 @@ export const StashBoxIDSearchModal: React.FC = ({ null ); const [query, setQuery] = useState(""); - const [results, setResults] = useState< - GQL.ScrapedPerformerDataFragment[] | undefined - >(undefined); + const [results, setResults] = useState( + undefined + ); const [loading, setLoading] = useState(false); useEffect(() => { @@ -168,17 +293,38 @@ export const StashBoxIDSearchModal: React.FC = ({ setResults([]); try { - const queryData = await stashBoxPerformerQuery( - query, - selectedStashBox.endpoint - ); - setResults(queryData.data?.scrapeSinglePerformer ?? []); + switch (entityType) { + case "performer": { + const queryData = await stashBoxPerformerQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSinglePerformer ?? []); + break; + } + case "scene": { + const queryData = await stashBoxSceneQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleScene ?? []); + break; + } + case "studio": { + const queryData = await stashBoxStudioQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleStudio ?? []); + break; + } + } } catch (error) { Toast.error(error); } finally { setLoading(false); } - }, [query, selectedStashBox, Toast]); + }, [query, selectedStashBox, Toast, entityType]); function handleItemClick(item: IHasRemoteSiteID) { if (selectedStashBox && item.remote_site_id) { @@ -195,6 +341,25 @@ export const StashBoxIDSearchModal: React.FC = ({ onSelectItem(undefined); } + function renderResultItem(item: SearchResultItem) { + switch (entityType) { + case "performer": + return ( + + ); + case "scene": + return ( + + ); + case "studio": + return ( + + ); + } + } + function renderResults() { if (!results || results.length === 0) { return null; @@ -204,14 +369,14 @@ export const StashBoxIDSearchModal: React.FC = ({
    {results.map((item, i) => (
  • handleItemClick(item)}> - + {renderResultItem(item)}
  • ))}
@@ -219,13 +384,17 @@ export const StashBoxIDSearchModal: React.FC = ({ ); } + const entityTypeDisplayName = intl.formatMessage({ + id: getEntityTypeMessageId(entityType), + }); + return ( = ({ value={query} placeholder={intl.formatMessage( { id: "stashbox_search.placeholder_name_or_id" }, - { entityType: "Performer" } + { entityType: entityTypeDisplayName } )} className="text-input" ref={inputRef} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index 264afdc7c..c8cfd3a3e 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -5,18 +5,22 @@ import * as yup from "yup"; import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { Form } from "react-bootstrap"; +import { Button, Form } from "react-bootstrap"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import isEqual from "lodash-es/isEqual"; import { useToast } from "src/hooks/Toast"; +import { useConfigurationContext } from "src/hooks/Config"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; +import { Icon } from "src/components/Shared/Icon"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; interface IStudioEditPanel { studio: Partial; @@ -37,9 +41,13 @@ export const StudioEditPanel: React.FC = ({ }) => { const intl = useIntl(); const Toast = useToast(); + const { configuration: stashConfig } = useConfigurationContext(); const isNew = studio.id === undefined; + // Editing state + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); + // Network state const [isLoading, setIsLoading] = useState(false); @@ -143,6 +151,14 @@ export const StudioEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) + ); + } + const { renderField, renderInputField, @@ -173,6 +189,20 @@ export const StudioEditPanel: React.FC = ({ return ( <> + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + /> + )} + { @@ -191,7 +221,21 @@ export const StudioEditPanel: React.FC = ({ {renderInputField("details", "textarea")} {renderParentStudioField()} {renderTagsField()} - {renderStashIDsField("stash_ids", "studios")} + {renderStashIDsField( + "stash_ids", + "studios", + "stash_ids", + undefined, + + )}
{renderInputField("ignore_auto_tag", "checkbox")} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e4c8b6a7c..c2b9b4247 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1015,6 +1015,7 @@ "video_previews_tooltip": "Video previews which play when hovering over a scene" }, "scenes_found": "{count} scenes found", + "studios_found": "{count} studios found", "scrape_entity_query": "{entity_type} Scrape Query", "scrape_entity_title": "{entity_type} Scrape Results", "scrape_results_existing": "Existing", diff --git a/ui/v2.5/src/utils/stashIds.ts b/ui/v2.5/src/utils/stashIds.ts index f44b182ab..92a4eaf1e 100644 --- a/ui/v2.5/src/utils/stashIds.ts +++ b/ui/v2.5/src/utils/stashIds.ts @@ -1,3 +1,5 @@ +import * as GQL from "src/core/generated-graphql"; + export const getStashIDs = ( ids?: { stash_id: string; endpoint: string; updated_at: string }[] ) => @@ -32,3 +34,25 @@ export const separateNamesAndStashIds = ( return { names, stashIds }; }; + +/** + * Utility to add or update a StashID in an array. + * If a StashID with the same endpoint exists, it will be replaced. + * Otherwise, the new StashID will be appended. + */ +export const addUpdateStashID = ( + existingStashIDs: GQL.StashIdInput[], + newItem: GQL.StashIdInput +): GQL.StashIdInput[] => { + const existingIndex = existingStashIDs.findIndex( + (s) => s.endpoint === newItem.endpoint + ); + + if (existingIndex >= 0) { + const newStashIDs = [...existingStashIDs]; + newStashIDs[existingIndex] = newItem; + return newStashIDs; + } + + return [...existingStashIDs, newItem]; +};