Feature: Manual StashId Search - Tags (#6374)

This commit is contained in:
Gykes 2025-12-03 18:20:29 -06:00 committed by GitHub
parent 877491e62b
commit 39fd8a6550
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 360 additions and 50 deletions

View file

@ -165,6 +165,12 @@ type Query {
input: ScrapeSingleStudioInput! input: ScrapeSingleStudioInput!
): [ScrapedStudio!]! ): [ScrapedStudio!]!
"Scrape for a single tag"
scrapeSingleTag(
source: ScraperSourceInput!
input: ScrapeSingleTagInput!
): [ScrapedTag!]!
"Scrape for a single performer" "Scrape for a single performer"
scrapeSinglePerformer( scrapeSinglePerformer(
source: ScraperSourceInput! source: ScraperSourceInput!

View file

@ -198,6 +198,13 @@ input ScrapeSingleStudioInput {
query: String query: String
} }
input ScrapeSingleTagInput {
"""
Query can be either a name or a Stash ID
"""
query: String
}
input ScrapeSinglePerformerInput { input ScrapeSinglePerformerInput {
"Instructs to query by string" "Instructs to query by string"
query: String query: String

View file

@ -170,6 +170,21 @@ query FindStudio($id: ID, $name: String) {
} }
} }
query FindTag($id: ID, $name: String) {
findTag(id: $id, name: $name) {
...TagFragment
}
}
query QueryTags($input: TagQueryInput!) {
queryTags(input: $input) {
count
tags {
...TagFragment
}
}
}
mutation SubmitFingerprint($input: FingerprintSubmission!) { mutation SubmitFingerprint($input: FingerprintSubmission!) {
submitFingerprint(input: $input) submitFingerprint(input: $input)
} }

View file

@ -353,6 +353,45 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
return nil, errors.New("stash_box_endpoint must be set") return nil, errors.New("stash_box_endpoint must be set")
} }
func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) {
if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)
if err != nil {
return nil, err
}
client := r.newStashBoxClient(*b)
var ret []*models.ScrapedTag
out, err := client.QueryTag(ctx, *input.Query)
if err != nil {
return nil, err
} else if out != nil {
ret = append(ret, out...)
}
if len(ret) > 0 {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
for _, tag := range ret {
if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
return nil, nil
}
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) { func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) {
var ret []*models.ScrapedPerformer var ret []*models.ScrapedPerformer
switch { switch {

View file

@ -18,6 +18,7 @@ type StashBoxGraphQLClient interface {
FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, 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) 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) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error)
QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error)
SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error)
Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error)
SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error)
@ -643,6 +644,24 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string {
return t.Name return t.Name
} }
type QueryTags_QueryTags struct {
Count int "json:\"count\" graphql:\"count\""
Tags []*TagFragment "json:\"tags\" graphql:\"tags\""
}
func (t *QueryTags_QueryTags) GetCount() int {
if t == nil {
t = &QueryTags_QueryTags{}
}
return t.Count
}
func (t *QueryTags_QueryTags) GetTags() []*TagFragment {
if t == nil {
t = &QueryTags_QueryTags{}
}
return t.Tags
}
type Me_Me struct { type Me_Me struct {
Name string "json:\"name\" graphql:\"name\"" Name string "json:\"name\" graphql:\"name\""
} }
@ -775,6 +794,17 @@ func (t *FindTag) GetFindTag() *TagFragment {
return t.FindTag return t.FindTag
} }
type QueryTags struct {
QueryTags QueryTags_QueryTags "json:\"queryTags\" graphql:\"queryTags\""
}
func (t *QueryTags) GetQueryTags() *QueryTags_QueryTags {
if t == nil {
t = &QueryTags{}
}
return &t.QueryTags
}
type SubmitFingerprint struct { type SubmitFingerprint struct {
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
} }
@ -1736,6 +1766,37 @@ func (c *Client) FindTag(ctx context.Context, id *string, name *string, intercep
return &res, nil return &res, nil
} }
const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) {
queryTags(input: $input) {
count
tags {
... TagFragment
}
}
}
fragment TagFragment on Tag {
name
id
}
`
func (c *Client) QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) {
vars := map[string]any{
"input": input,
}
var res QueryTags
if err := c.Client.Post(ctx, "QueryTags", QueryTagsDocument, &res, vars, interceptors...); err != nil {
if c.Client.ParseDataWhenErrors {
return &res, err
}
return nil, err
}
return &res, nil
}
const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) { const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) {
submitFingerprint(input: $input) submitFingerprint(input: $input)
} }
@ -1838,6 +1899,7 @@ var DocumentOperationNames = map[string]string{
FindSceneByIDDocument: "FindSceneByID", FindSceneByIDDocument: "FindSceneByID",
FindStudioDocument: "FindStudio", FindStudioDocument: "FindStudio",
FindTagDocument: "FindTag", FindTagDocument: "FindTag",
QueryTagsDocument: "QueryTags",
SubmitFingerprintDocument: "SubmitFingerprint", SubmitFingerprintDocument: "SubmitFingerprint",
MeDocument: "Me", MeDocument: "Me",
SubmitSceneDraftDocument: "SubmitSceneDraft", SubmitSceneDraftDocument: "SubmitSceneDraft",

67
pkg/stashbox/tag.go Normal file
View file

@ -0,0 +1,67 @@
package stashbox
import (
"context"
"github.com/google/uuid"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/stashbox/graphql"
)
// QueryTag searches for tags by name or ID.
// If query is a valid UUID, it searches by ID (returns single result).
// Otherwise, it searches by name (returns multiple results).
func (c Client) QueryTag(ctx context.Context, query string) ([]*models.ScrapedTag, error) {
_, err := uuid.Parse(query)
if err == nil {
// Query is a UUID, use findTag for exact match
return c.findTagByID(ctx, query)
}
// Otherwise search by name
return c.queryTagsByName(ctx, query)
}
func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTag, error) {
tag, err := c.client.FindTag(ctx, &id, nil)
if err != nil {
return nil, err
}
if tag.FindTag == nil {
return nil, nil
}
return []*models.ScrapedTag{{
Name: tag.FindTag.Name,
RemoteSiteID: &tag.FindTag.ID,
}}, nil
}
func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) {
input := graphql.TagQueryInput{
Name: &name,
Page: 1,
PerPage: 25,
Direction: graphql.SortDirectionEnumAsc,
Sort: graphql.TagSortEnumName,
}
result, err := c.client.QueryTags(ctx, input)
if err != nil {
return nil, err
}
if result.QueryTags.Tags == nil {
return nil, nil
}
var ret []*models.ScrapedTag
for _, t := range result.QueryTags.Tags {
ret = append(ret, &models.ScrapedTag{
Name: t.Name,
RemoteSiteID: &t.ID,
})
}
return ret, nil
}

View file

@ -62,6 +62,15 @@ query ScrapeSingleStudio(
} }
} }
query ScrapeSingleTag(
$source: ScraperSourceInput!
$input: ScrapeSingleTagInput!
) {
scrapeSingleTag(source: $source, input: $input) {
...ScrapedSceneTagData
}
}
query ScrapeSinglePerformer( query ScrapeSinglePerformer(
$source: ScraperSourceInput! $source: ScraperSourceInput!
$input: ScrapeSinglePerformerInput! $input: ScrapeSinglePerformerInput!

View file

@ -15,6 +15,7 @@ import {
stashBoxPerformerQuery, stashBoxPerformerQuery,
stashBoxSceneQuery, stashBoxSceneQuery,
stashBoxStudioQuery, stashBoxStudioQuery,
stashBoxTagQuery,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { stringToGender } from "src/utils/gender"; import { stringToGender } from "src/utils/gender";
@ -22,9 +23,10 @@ import { stringToGender } from "src/utils/gender";
type SearchResultItem = type SearchResultItem =
| GQL.ScrapedPerformerDataFragment | GQL.ScrapedPerformerDataFragment
| GQL.ScrapedSceneDataFragment | GQL.ScrapedSceneDataFragment
| GQL.ScrapedStudioDataFragment; | GQL.ScrapedStudioDataFragment
| GQL.ScrapedSceneTagDataFragment;
export type StashBoxEntityType = "performer" | "scene" | "studio"; export type StashBoxEntityType = "performer" | "scene" | "studio" | "tag";
interface IProps { interface IProps {
entityType: StashBoxEntityType; entityType: StashBoxEntityType;
@ -232,6 +234,27 @@ export const StudioSearchResult: React.FC<IStudioResultProps> = ({
); );
}; };
// Tag Result Component
interface ITagResultProps {
tag: GQL.ScrapedSceneTagDataFragment;
}
export const TagSearchResult: React.FC<ITagResultProps> = ({ tag }) => {
return (
<div className="mt-3 search-item" style={{ cursor: "pointer" }}>
<div className="tag-result">
<Row>
<div className="col flex-column">
<h4 className="tag-name">
<span>{tag.name}</span>
</h4>
</div>
</Row>
</div>
</div>
);
};
// Helper to get entity type message id for i18n // Helper to get entity type message id for i18n
function getEntityTypeMessageId(entityType: StashBoxEntityType): string { function getEntityTypeMessageId(entityType: StashBoxEntityType): string {
switch (entityType) { switch (entityType) {
@ -241,6 +264,8 @@ function getEntityTypeMessageId(entityType: StashBoxEntityType): string {
return "scene"; return "scene";
case "studio": case "studio":
return "studio"; return "studio";
case "tag":
return "tag";
} }
} }
@ -253,6 +278,8 @@ function getFoundMessageId(entityType: StashBoxEntityType): string {
return "dialogs.scenes_found"; return "dialogs.scenes_found";
case "studio": case "studio":
return "dialogs.studios_found"; return "dialogs.studios_found";
case "tag":
return "dialogs.tags_found";
} }
} }
@ -318,6 +345,14 @@ export const StashBoxIDSearchModal: React.FC<IProps> = ({
setResults(queryData.data?.scrapeSingleStudio ?? []); setResults(queryData.data?.scrapeSingleStudio ?? []);
break; break;
} }
case "tag": {
const queryData = await stashBoxTagQuery(
query,
selectedStashBox.endpoint
);
setResults(queryData.data?.scrapeSingleTag ?? []);
break;
}
} }
} catch (error) { } catch (error) {
Toast.error(error); Toast.error(error);
@ -357,6 +392,10 @@ export const StashBoxIDSearchModal: React.FC<IProps> = ({
return ( return (
<StudioSearchResult studio={item as GQL.ScrapedStudioDataFragment} /> <StudioSearchResult studio={item as GQL.ScrapedStudioDataFragment} />
); );
case "tag":
return (
<TagSearchResult tag={item as GQL.ScrapedSceneTagDataFragment} />
);
} }
} }

View file

@ -3,7 +3,8 @@ import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import * as yup from "yup"; import * as yup from "yup";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; 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 ImageUtils from "src/utils/image";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { Prompt } from "react-router-dom"; import { Prompt } from "react-router-dom";
@ -11,11 +12,14 @@ import Mousetrap from "mousetrap";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { useConfigurationContext } from "src/hooks/Config";
import { handleUnsavedChanges } from "src/utils/navigation"; import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
import { getStashIDs } from "src/utils/stashIds"; import { addUpdateStashID, getStashIDs } from "src/utils/stashIds";
import { Tag, TagSelect } from "../TagSelect"; import { Tag, TagSelect } from "../TagSelect";
import { Icon } from "src/components/Shared/Icon";
import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal";
interface ITagEditPanel { interface ITagEditPanel {
tag: Partial<GQL.TagDataFragment>; tag: Partial<GQL.TagDataFragment>;
@ -36,9 +40,13 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const { configuration: stashConfig } = useConfigurationContext();
const isNew = tag.id === undefined; const isNew = tag.id === undefined;
// Editing state
const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false);
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -143,6 +151,14 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
ImageUtils.onImageChange(event, onImageLoad); ImageUtils.onImageChange(event, onImageLoad);
} }
function onStashIDSelected(item?: GQL.StashIdInput) {
if (!item) return;
formik.setFieldValue(
"stash_ids",
addUpdateStashID(formik.values.stash_ids, item)
);
}
const { const {
renderField, renderField,
renderInputField, renderInputField,
@ -186,54 +202,86 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
// TODO: CSS class // TODO: CSS class
return ( return (
<div> <>
{isNew && ( {isStashIDSearchOpen && (
<h2> <StashBoxIDSearchModal
<FormattedMessage entityType="tag"
id="actions.add_entity" stashBoxes={stashConfig?.general.stashBoxes ?? []}
values={{ entityType: intl.formatMessage({ id: "tag" }) }} excludedStashBoxEndpoints={formik.values.stash_ids.map(
/> (s) => s.endpoint
</h2> )}
onSelectItem={(item) => {
onStashIDSelected(item);
setIsStashIDSearchOpen(false);
}}
/>
)} )}
<Prompt <div>
when={formik.dirty} {isNew && (
message={(location, action) => { <h2>
// Check if it's a redirect after tag creation <FormattedMessage
if (action === "PUSH" && location.pathname.startsWith("/tags/")) { id="actions.add_entity"
return true; values={{ entityType: intl.formatMessage({ id: "tag" }) }}
/>
</h2>
)}
<Prompt
when={formik.dirty}
message={(location, action) => {
// Check if it's a redirect after tag creation
if (action === "PUSH" && location.pathname.startsWith("/tags/")) {
return true;
}
return handleUnsavedChanges(intl, "tags", tag.id)(location);
}}
/>
<Form noValidate onSubmit={formik.handleSubmit} id="tag-edit">
{renderInputField("name")}
{renderInputField("sort_name", "text")}
{renderStringListField("aliases")}
{renderInputField("description", "textarea")}
{renderParentTagsField()}
{renderSubTagsField()}
{renderStashIDsField(
"stash_ids",
"tags",
"stash_ids",
undefined,
<Button
variant="success"
className="mr-2 py-0"
onClick={() => setIsStashIDSearchOpen(true)}
disabled={!stashConfig?.general.stashBoxes?.length}
title={intl.formatMessage({ id: "actions.add_stash_id" })}
>
<Icon icon={faPlus} />
</Button>
)}
<hr />
{renderInputField("ignore_auto_tag", "checkbox")}
</Form>
<DetailsEditNavbar
objectName={tag?.name ?? intl.formatMessage({ id: "tag" })}
classNames="col-xl-9 mt-3"
isNew={isNew}
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
saveDisabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
} }
onImageChange={onImageChange}
return handleUnsavedChanges(intl, "tags", tag.id)(location); onImageChangeURL={onImageLoad}
}} onClearImage={() => onImageLoad(null)}
/> onDelete={onDelete}
acceptSVG
<Form noValidate onSubmit={formik.handleSubmit} id="tag-edit"> />
{renderInputField("name")} </div>
{renderInputField("sort_name", "text")} </>
{renderStringListField("aliases")}
{renderInputField("description", "textarea")}
{renderParentTagsField()}
{renderSubTagsField()}
{renderStashIDsField("stash_ids", "tags")}
<hr />
{renderInputField("ignore_auto_tag", "checkbox")}
</Form>
<DetailsEditNavbar
objectName={tag?.name ?? intl.formatMessage({ id: "tag" })}
classNames="col-xl-9 mt-3"
isNew={isNew}
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onImageChange}
onImageChangeURL={onImageLoad}
onClearImage={() => onImageLoad(null)}
onDelete={onDelete}
acceptSVG
/>
</div>
); );
}; };

View file

@ -2329,6 +2329,23 @@ export const stashBoxSceneQuery = (query: string, stashBoxEndpoint: string) =>
} }
); );
export const stashBoxTagQuery = (
query: string | null,
stashBoxEndpoint: string
) =>
client.query<GQL.ScrapeSingleTagQuery, GQL.ScrapeSingleTagQueryVariables>({
query: GQL.ScrapeSingleTagDocument,
variables: {
source: {
stash_box_endpoint: stashBoxEndpoint,
},
input: {
query: query,
},
},
fetchPolicy: "network-only",
});
export const mutateStashBoxBatchPerformerTag = ( export const mutateStashBoxBatchPerformerTag = (
input: GQL.StashBoxBatchTagInput input: GQL.StashBoxBatchTagInput
) => ) =>

View file

@ -1016,6 +1016,7 @@
}, },
"scenes_found": "{count} scenes found", "scenes_found": "{count} scenes found",
"studios_found": "{count} studios found", "studios_found": "{count} studios found",
"tags_found": "{count} tags found",
"scrape_entity_query": "{entity_type} Scrape Query", "scrape_entity_query": "{entity_type} Scrape Query",
"scrape_entity_title": "{entity_type} Scrape Results", "scrape_entity_title": "{entity_type} Scrape Results",
"scrape_results_existing": "Existing", "scrape_results_existing": "Existing",