mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +03:00
Generalise tagger view to all scraping sources (#1812)
* Refactor Tagger view * Support link to existing studio/performer * Allow stash id field selection
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
* Generalised Tagger view to support tagging using supported scene scrapers. ([#1812](https://github.com/stashapp/stash/pull/1812))
|
||||||
* Added built-in `Auto Tag` scene scraper to match performers, studio and tags from filename - using AutoTag logic. ([#1817](https://github.com/stashapp/stash/pull/1817))
|
* Added built-in `Auto Tag` scene scraper to match performers, studio and tags from filename - using AutoTag logic. ([#1817](https://github.com/stashapp/stash/pull/1817))
|
||||||
* Added interface options to disable creating performers/studios/tags from dropdown selectors. ([#1814](https://github.com/stashapp/stash/pull/1814))
|
* Added interface options to disable creating performers/studios/tags from dropdown selectors. ([#1814](https://github.com/stashapp/stash/pull/1814))
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { DeleteScenesDialog } from "./DeleteScenesDialog";
|
|||||||
import { SceneGenerateDialog } from "./SceneGenerateDialog";
|
import { SceneGenerateDialog } from "./SceneGenerateDialog";
|
||||||
import { ExportDialog } from "../Shared/ExportDialog";
|
import { ExportDialog } from "../Shared/ExportDialog";
|
||||||
import { SceneCardsGrid } from "./SceneCardsGrid";
|
import { SceneCardsGrid } from "./SceneCardsGrid";
|
||||||
|
import { TaggerContext } from "../Tagger/context";
|
||||||
|
|
||||||
interface ISceneList {
|
interface ISceneList {
|
||||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||||
@@ -253,5 +254,5 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return listData.template;
|
return <TaggerContext>{listData.template}</TaggerContext>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface ILoadingProps {
|
|||||||
message?: string;
|
message?: string;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
small?: boolean;
|
small?: boolean;
|
||||||
|
card?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLASSNAME = "LoadingIndicator";
|
const CLASSNAME = "LoadingIndicator";
|
||||||
@@ -15,8 +16,9 @@ const LoadingIndicator: React.FC<ILoadingProps> = ({
|
|||||||
message,
|
message,
|
||||||
inline = false,
|
inline = false,
|
||||||
small = false,
|
small = false,
|
||||||
|
card = false,
|
||||||
}) => (
|
}) => (
|
||||||
<div className={cx(CLASSNAME, { inline, small })}>
|
<div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
|
||||||
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
|
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
|
||||||
<span className="sr-only">Loading...</span>
|
<span className="sr-only">Loading...</span>
|
||||||
</Spinner>
|
</Spinner>
|
||||||
|
|||||||
56
ui/v2.5/src/components/Shared/OperationButton.tsx
Normal file
56
ui/v2.5/src/components/Shared/OperationButton.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import { Button, ButtonProps } from "react-bootstrap";
|
||||||
|
import { LoadingIndicator } from "src/components/Shared";
|
||||||
|
|
||||||
|
interface IOperationButton extends ButtonProps {
|
||||||
|
operation?: () => Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
hideChildrenWhenLoading?: boolean;
|
||||||
|
setLoading?: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OperationButton: React.FC<IOperationButton> = (props) => {
|
||||||
|
const [internalLoading, setInternalLoading] = useState(false);
|
||||||
|
const mounted = useRef(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
operation,
|
||||||
|
loading: externalLoading,
|
||||||
|
hideChildrenWhenLoading = false,
|
||||||
|
setLoading: setExternalLoading,
|
||||||
|
...withoutExtras
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLoading = setExternalLoading || setInternalLoading;
|
||||||
|
const loading =
|
||||||
|
externalLoading !== undefined ? externalLoading : internalLoading;
|
||||||
|
|
||||||
|
async function handleClick() {
|
||||||
|
if (operation) {
|
||||||
|
setLoading(true);
|
||||||
|
await operation();
|
||||||
|
|
||||||
|
if (mounted.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={handleClick} {...withoutExtras}>
|
||||||
|
{loading && (
|
||||||
|
<span className="mr-2">
|
||||||
|
<LoadingIndicator message="" inline small />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(!loading || !hideChildrenWhenLoading) && props.children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 70vh;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
&:not(.card-based) {
|
||||||
|
height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
&-message {
|
&-message {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Dispatch, useRef } from "react";
|
import React, { useRef, useContext } from "react";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -9,29 +9,18 @@ import {
|
|||||||
} from "react-bootstrap";
|
} from "react-bootstrap";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { Icon } from "src/components/Shared";
|
import { Icon } from "src/components/Shared";
|
||||||
import { useConfiguration } from "src/core/StashService";
|
import { ParseMode, TagOperation } from "./constants";
|
||||||
|
import { TaggerStateContext } from "./context";
|
||||||
import { ITaggerConfig, ParseMode, TagOperation } from "./constants";
|
|
||||||
|
|
||||||
interface IConfigProps {
|
interface IConfigProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
config: ITaggerConfig;
|
|
||||||
setConfig: Dispatch<ITaggerConfig>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
const Config: React.FC<IConfigProps> = ({ show }) => {
|
||||||
|
const { config, setConfig } = useContext(TaggerStateContext);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const stashConfig = useConfiguration();
|
|
||||||
const blacklistRef = useRef<HTMLInputElement | null>(null);
|
const blacklistRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const handleInstanceSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const selectedEndpoint = e.currentTarget.value;
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
selectedEndpoint,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeBlacklist = (index: number) => {
|
const removeBlacklist = (index: number) => {
|
||||||
setConfig({
|
setConfig({
|
||||||
...config,
|
...config,
|
||||||
@@ -55,8 +44,6 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||||||
blacklistRef.current.value = "";
|
blacklistRef.current.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse in={show}>
|
<Collapse in={show}>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -221,29 +208,6 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Form.Group
|
|
||||||
controlId="stash-box-endpoint"
|
|
||||||
className="align-items-center row no-gutters mt-4"
|
|
||||||
>
|
|
||||||
<Form.Label className="mr-4">
|
|
||||||
<FormattedMessage id="component_tagger.config.active_instance" />
|
|
||||||
</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
as="select"
|
|
||||||
value={config.selectedEndpoint}
|
|
||||||
className="col-md-4 col-6 input-control"
|
|
||||||
disabled={!stashBoxes.length}
|
|
||||||
onChange={handleInstanceSelect}
|
|
||||||
>
|
|
||||||
{!stashBoxes.length && <option>No instances found</option>}
|
|
||||||
{stashConfig.data?.configuration.general.stashBoxes.map((i) => (
|
|
||||||
<option value={i.endpoint} key={i.endpoint}>
|
|
||||||
{i.endpoint}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Form.Control>
|
|
||||||
</Form.Group>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const IncludeExcludeButton: React.FC<IIncludeExcludeButton> = ({
|
|||||||
|
|
||||||
interface IOptionalField {
|
interface IOptionalField {
|
||||||
exclude: boolean;
|
exclude: boolean;
|
||||||
|
title?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
setExclude: (v: boolean) => void;
|
setExclude: (v: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -35,9 +36,13 @@ export const OptionalField: React.FC<IOptionalField> = ({
|
|||||||
exclude,
|
exclude,
|
||||||
setExclude,
|
setExclude,
|
||||||
children,
|
children,
|
||||||
}) => (
|
title,
|
||||||
<div className={`optional-field ${!exclude ? "included" : "excluded"}`}>
|
}) => {
|
||||||
<IncludeExcludeButton exclude={exclude} setExclude={setExclude} />
|
return (
|
||||||
{children}
|
<div className={`optional-field ${!exclude ? "included" : "excluded"}`}>
|
||||||
</div>
|
<IncludeExcludeButton exclude={exclude} setExclude={setExclude} />
|
||||||
);
|
{title && <span className="optional-field-title">{title}</span>}
|
||||||
|
<div className="optional-field-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { IconName } from "@fortawesome/fontawesome-svg-core";
|
import { IconName } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
|
||||||
@@ -11,26 +11,24 @@ import {
|
|||||||
TruncatedText,
|
TruncatedText,
|
||||||
} from "src/components/Shared";
|
} from "src/components/Shared";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { TextUtils } from "src/utils";
|
import { genderToString, stringToGender } from "src/utils/gender";
|
||||||
import { genderToString } from "src/utils/gender";
|
|
||||||
import { IStashBoxPerformer } from "./utils";
|
|
||||||
|
|
||||||
interface IPerformerModalProps {
|
interface IPerformerModalProps {
|
||||||
performer: IStashBoxPerformer;
|
performer: GQL.ScrapedScenePerformerDataFragment;
|
||||||
modalVisible: boolean;
|
modalVisible: boolean;
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
handlePerformerCreate: (imageIndex: number, excludedFields: string[]) => void;
|
onSave: (input: GQL.PerformerCreateInput) => void;
|
||||||
excludedPerformerFields?: string[];
|
excludedPerformerFields?: string[];
|
||||||
header: string;
|
header: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
create?: boolean;
|
create?: boolean;
|
||||||
endpoint: string;
|
endpoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PerformerModal: React.FC<IPerformerModalProps> = ({
|
const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||||
modalVisible,
|
modalVisible,
|
||||||
performer,
|
performer,
|
||||||
handlePerformerCreate,
|
onSave,
|
||||||
closeModal,
|
closeModal,
|
||||||
excludedPerformerFields = [],
|
excludedPerformerFields = [],
|
||||||
header,
|
header,
|
||||||
@@ -39,6 +37,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
|||||||
endpoint,
|
endpoint,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const [imageIndex, setImageIndex] = useState(0);
|
const [imageIndex, setImageIndex] = useState(0);
|
||||||
const [imageState, setImageState] = useState<
|
const [imageState, setImageState] = useState<
|
||||||
"loading" | "error" | "loaded" | "empty"
|
"loading" | "error" | "loaded" | "empty"
|
||||||
@@ -51,7 +50,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const { images } = performer;
|
const images = performer.images ?? [];
|
||||||
|
|
||||||
const changeImage = (index: number) => {
|
const changeImage = (index: number) => {
|
||||||
setImageIndex(index);
|
setImageIndex(index);
|
||||||
@@ -94,7 +93,9 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
|||||||
<Icon icon={excluded[name] ? "times" : "check"} />
|
<Icon icon={excluded[name] ? "times" : "check"} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<strong>{TextUtils.capitalize(name)}:</strong>
|
<strong>
|
||||||
|
<FormattedMessage id={name} />:
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
{truncate ? (
|
{truncate ? (
|
||||||
<TruncatedText className="col-7" text={text} />
|
<TruncatedText className="col-7" text={text} />
|
||||||
@@ -104,19 +105,77 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const base = endpoint.match(/https?:\/\/.*?\//)?.[0];
|
const base = endpoint?.match(/https?:\/\/.*?\//)?.[0];
|
||||||
const link = base ? `${base}performers/${performer.stash_id}` : undefined;
|
const link = base
|
||||||
|
? `${base}performers/${performer.remote_site_id}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function onSaveClicked() {
|
||||||
|
if (!performer.name) {
|
||||||
|
throw new Error("performer name must set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const performerData: GQL.PerformerCreateInput = {
|
||||||
|
name: performer.name ?? "",
|
||||||
|
aliases: performer.aliases,
|
||||||
|
gender: stringToGender(performer.gender ?? undefined),
|
||||||
|
birthdate: performer.birthdate,
|
||||||
|
ethnicity: performer.ethnicity,
|
||||||
|
eye_color: performer.eye_color,
|
||||||
|
country: performer.country,
|
||||||
|
height: performer.height,
|
||||||
|
measurements: performer.measurements,
|
||||||
|
fake_tits: performer.fake_tits,
|
||||||
|
career_length: performer.career_length,
|
||||||
|
tattoos: performer.tattoos,
|
||||||
|
piercings: performer.piercings,
|
||||||
|
url: performer.url,
|
||||||
|
twitter: performer.twitter,
|
||||||
|
instagram: performer.instagram,
|
||||||
|
image: images.length > imageIndex ? images[imageIndex] : undefined,
|
||||||
|
details: performer.details,
|
||||||
|
death_date: performer.death_date,
|
||||||
|
hair_color: performer.hair_color,
|
||||||
|
weight: Number.parseFloat(performer.weight ?? "") ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Number.isNaN(performerData.weight ?? 0)) {
|
||||||
|
performerData.weight = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (performer.tags) {
|
||||||
|
performerData.tag_ids = performer.tags
|
||||||
|
.map((t) => t.stored_id)
|
||||||
|
.filter((t) => t) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// stashid handling code
|
||||||
|
const remoteSiteID = performer.remote_site_id;
|
||||||
|
if (remoteSiteID && endpoint) {
|
||||||
|
performerData.stash_ids = [
|
||||||
|
{
|
||||||
|
endpoint,
|
||||||
|
stash_id: remoteSiteID,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle exclusions
|
||||||
|
Object.keys(performerData).forEach((k) => {
|
||||||
|
if (excludedPerformerFields.includes(k) || excluded[k]) {
|
||||||
|
(performerData as Record<string, unknown>)[k] = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onSave(performerData);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
show={modalVisible}
|
show={modalVisible}
|
||||||
accept={{
|
accept={{
|
||||||
text: intl.formatMessage({ id: "actions.save" }),
|
text: intl.formatMessage({ id: "actions.save" }),
|
||||||
onClick: () =>
|
onClick: onSaveClicked,
|
||||||
handlePerformerCreate(
|
|
||||||
imageIndex,
|
|
||||||
create ? [] : Object.keys(excluded).filter((key) => excluded[key])
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
cancel={{ onClick: () => closeModal(), variant: "secondary" }}
|
cancel={{ onClick: () => closeModal(), variant: "secondary" }}
|
||||||
onHide={() => closeModal()}
|
onHide={() => closeModal()}
|
||||||
@@ -127,7 +186,10 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-7">
|
<div className="col-7">
|
||||||
{renderField("name", performer.name)}
|
{renderField("name", performer.name)}
|
||||||
{renderField("gender", genderToString(performer.gender))}
|
{renderField(
|
||||||
|
"gender",
|
||||||
|
performer.gender ? genderToString(performer.gender) : ""
|
||||||
|
)}
|
||||||
{renderField("birthdate", performer.birthdate)}
|
{renderField("birthdate", performer.birthdate)}
|
||||||
{renderField("death_date", performer.death_date)}
|
{renderField("death_date", performer.death_date)}
|
||||||
{renderField("ethnicity", performer.ethnicity)}
|
{renderField("ethnicity", performer.ethnicity)}
|
||||||
@@ -142,6 +204,11 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
|||||||
{renderField("career_length", performer.career_length)}
|
{renderField("career_length", performer.career_length)}
|
||||||
{renderField("tattoos", performer.tattoos, false)}
|
{renderField("tattoos", performer.tattoos, false)}
|
||||||
{renderField("piercings", performer.piercings, false)}
|
{renderField("piercings", performer.piercings, false)}
|
||||||
|
{renderField("weight", performer.weight, false)}
|
||||||
|
{renderField("details", performer.details)}
|
||||||
|
{renderField("url", performer.url)}
|
||||||
|
{renderField("twitter", performer.twitter)}
|
||||||
|
{renderField("instagram", performer.instagram)}
|
||||||
{link && (
|
{link && (
|
||||||
<h6 className="mt-2">
|
<h6 className="mt-2">
|
||||||
<a href={link} target="_blank" rel="noopener noreferrer">
|
<a href={link} target="_blank" rel="noopener noreferrer">
|
||||||
|
|||||||
@@ -1,120 +1,60 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { Button, ButtonGroup } from "react-bootstrap";
|
import { Button, ButtonGroup } from "react-bootstrap";
|
||||||
import { FormattedMessage } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
|
|
||||||
import { PerformerSelect } from "src/components/Shared";
|
import { Icon, PerformerSelect } from "src/components/Shared";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { ValidTypes } from "src/components/Shared/Select";
|
import { ValidTypes } from "src/components/Shared/Select";
|
||||||
import { IStashBoxPerformer, filterPerformer } from "./utils";
|
|
||||||
|
|
||||||
import PerformerModal from "./PerformerModal";
|
|
||||||
import { OptionalField } from "./IncludeButton";
|
import { OptionalField } from "./IncludeButton";
|
||||||
|
import { OperationButton } from "../Shared/OperationButton";
|
||||||
export type PerformerOperation =
|
|
||||||
| { type: "create"; data: IStashBoxPerformer }
|
|
||||||
| { type: "update"; data: GQL.SlimPerformerDataFragment }
|
|
||||||
| { type: "existing"; data: GQL.PerformerDataFragment }
|
|
||||||
| { type: "skip" };
|
|
||||||
|
|
||||||
export interface IPerformerOperations {
|
|
||||||
[x: string]: PerformerOperation;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IPerformerResultProps {
|
interface IPerformerResultProps {
|
||||||
performer: IStashBoxPerformer;
|
performer: GQL.ScrapedPerformer;
|
||||||
setPerformer: (data: PerformerOperation) => void;
|
selectedID: string | undefined;
|
||||||
endpoint: string;
|
setSelectedID: (id: string | undefined) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onLink?: () => Promise<void>;
|
||||||
|
endpoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PerformerResult: React.FC<IPerformerResultProps> = ({
|
const PerformerResult: React.FC<IPerformerResultProps> = ({
|
||||||
performer,
|
performer,
|
||||||
setPerformer,
|
selectedID,
|
||||||
|
setSelectedID,
|
||||||
|
onCreate,
|
||||||
|
onLink,
|
||||||
endpoint,
|
endpoint,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedPerformer, setSelectedPerformer] = useState<string | null>();
|
const {
|
||||||
const [selectedSource, setSelectedSource] = useState<
|
data: performerData,
|
||||||
"create" | "existing" | "skip" | undefined
|
loading: stashLoading,
|
||||||
>();
|
} = GQL.useFindPerformerQuery({
|
||||||
const [modalVisible, showModal] = useState(false);
|
variables: { id: performer.stored_id ?? "" },
|
||||||
const { data: performerData } = GQL.useFindPerformerQuery({
|
skip: !performer.stored_id,
|
||||||
variables: { id: performer.id ?? "" },
|
|
||||||
skip: !performer.id,
|
|
||||||
});
|
});
|
||||||
const { data: stashData, loading: stashLoading } = GQL.useFindPerformersQuery(
|
|
||||||
{
|
|
||||||
variables: {
|
|
||||||
performer_filter: {
|
|
||||||
stash_id: {
|
|
||||||
value: performer.stash_id,
|
|
||||||
modifier: GQL.CriterionModifier.Equals,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const matchedPerformer = performerData?.findPerformer;
|
||||||
if (stashData?.findPerformers.performers.length)
|
const matchedStashID = matchedPerformer?.stash_ids.some(
|
||||||
setPerformer({
|
(stashID) => stashID.endpoint === endpoint && stashID.stash_id
|
||||||
type: "existing",
|
);
|
||||||
data: stashData.findPerformers.performers[0],
|
|
||||||
});
|
|
||||||
else if (performerData?.findPerformer) {
|
|
||||||
setSelectedPerformer(performerData.findPerformer.id);
|
|
||||||
setSelectedSource("existing");
|
|
||||||
setPerformer({
|
|
||||||
type: "update",
|
|
||||||
data: performerData.findPerformer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [stashData, performerData]);
|
|
||||||
|
|
||||||
const handlePerformerSelect = (performers: ValidTypes[]) => {
|
const handlePerformerSelect = (performers: ValidTypes[]) => {
|
||||||
if (performers.length) {
|
if (performers.length) {
|
||||||
setSelectedSource("existing");
|
setSelectedID(performers[0].id);
|
||||||
setSelectedPerformer(performers[0].id);
|
|
||||||
setPerformer({
|
|
||||||
type: "update",
|
|
||||||
data: performers[0] as GQL.SlimPerformerDataFragment,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setSelectedSource(undefined);
|
setSelectedID(undefined);
|
||||||
setSelectedPerformer(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePerformerCreate = (
|
|
||||||
imageIndex: number,
|
|
||||||
excludedFields: string[]
|
|
||||||
) => {
|
|
||||||
const selectedImage = performer.images[imageIndex];
|
|
||||||
const images = selectedImage ? [selectedImage] : [];
|
|
||||||
|
|
||||||
setSelectedSource("create");
|
|
||||||
setPerformer({
|
|
||||||
type: "create",
|
|
||||||
data: {
|
|
||||||
...filterPerformer(performer, excludedFields),
|
|
||||||
name: performer.name,
|
|
||||||
stash_id: performer.stash_id,
|
|
||||||
images,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
showModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePerformerSkip = () => {
|
const handlePerformerSkip = () => {
|
||||||
setSelectedSource("skip");
|
setSelectedID(undefined);
|
||||||
setPerformer({
|
|
||||||
type: "skip",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (stashLoading) return <div>Loading performer</div>;
|
if (stashLoading) return <div>Loading performer</div>;
|
||||||
|
|
||||||
if (stashData?.findPerformers.performers?.[0]?.id) {
|
if (matchedPerformer && matchedStashID) {
|
||||||
return (
|
return (
|
||||||
<div className="row no-gutters my-2">
|
<div className="row no-gutters my-2">
|
||||||
<div className="entity-name">
|
<div className="entity-name">
|
||||||
@@ -123,45 +63,48 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<span className="ml-auto">
|
<span className="ml-auto">
|
||||||
<OptionalField
|
<OptionalField
|
||||||
exclude={selectedSource === "skip"}
|
exclude={selectedID === undefined}
|
||||||
setExclude={(v) =>
|
setExclude={(v) =>
|
||||||
v ? handlePerformerSkip() : setSelectedSource("existing")
|
v ? handlePerformerSkip() : setSelectedID(matchedPerformer.id)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="mr-2">
|
<span className="mr-2">
|
||||||
<FormattedMessage id="component_tagger.verb_matched" />:
|
<FormattedMessage id="component_tagger.verb_matched" />:
|
||||||
</span>
|
</span>
|
||||||
<b className="col-3 text-right">
|
<b className="col-3 text-right">{matchedPerformer.name}</b>
|
||||||
{stashData.findPerformers.performers[0].name}
|
|
||||||
</b>
|
|
||||||
</div>
|
</div>
|
||||||
</OptionalField>
|
</OptionalField>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeRenderLinkButton() {
|
||||||
|
if (endpoint && onLink) {
|
||||||
|
return (
|
||||||
|
<OperationButton
|
||||||
|
variant="secondary"
|
||||||
|
disabled={selectedID === undefined}
|
||||||
|
operation={onLink}
|
||||||
|
hideChildrenWhenLoading
|
||||||
|
>
|
||||||
|
<Icon icon="save" />
|
||||||
|
</OperationButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSource = !selectedID ? "skip" : "existing";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row no-gutters align-items-center mt-2">
|
<div className="row no-gutters align-items-center mt-2">
|
||||||
<PerformerModal
|
|
||||||
closeModal={() => showModal(false)}
|
|
||||||
modalVisible={modalVisible}
|
|
||||||
performer={performer}
|
|
||||||
handlePerformerCreate={handlePerformerCreate}
|
|
||||||
icon="star"
|
|
||||||
header="Create Performer"
|
|
||||||
create
|
|
||||||
endpoint={endpoint}
|
|
||||||
/>
|
|
||||||
<div className="entity-name">
|
<div className="entity-name">
|
||||||
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
|
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
|
||||||
<b className="ml-2">{performer.name}</b>
|
<b className="ml-2">{performer.name}</b>
|
||||||
</div>
|
</div>
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<Button
|
<Button variant="secondary" onClick={() => onCreate()}>
|
||||||
variant={selectedSource === "create" ? "primary" : "secondary"}
|
|
||||||
onClick={() => showModal(true)}
|
|
||||||
>
|
|
||||||
<FormattedMessage id="actions.create" />
|
<FormattedMessage id="actions.create" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -171,13 +114,14 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
|
|||||||
<FormattedMessage id="actions.skip" />
|
<FormattedMessage id="actions.skip" />
|
||||||
</Button>
|
</Button>
|
||||||
<PerformerSelect
|
<PerformerSelect
|
||||||
ids={selectedPerformer ? [selectedPerformer] : []}
|
ids={selectedID ? [selectedID] : []}
|
||||||
onSelect={handlePerformerSelect}
|
onSelect={handlePerformerSelect}
|
||||||
className={cx("performer-select", {
|
className={cx("performer-select", {
|
||||||
"performer-select-active": selectedSource === "existing",
|
"performer-select-active": selectedSource === "existing",
|
||||||
})}
|
})}
|
||||||
isClearable={false}
|
isClearable={false}
|
||||||
/>
|
/>
|
||||||
|
{maybeRenderLinkButton()}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useReducer, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import { Badge, Button, Col, Form, Row } from "react-bootstrap";
|
import { Badge, Button, Col, Form, Row } from "react-bootstrap";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
@@ -13,22 +13,30 @@ import {
|
|||||||
} from "src/components/Shared";
|
} from "src/components/Shared";
|
||||||
import { FormUtils } from "src/utils";
|
import { FormUtils } from "src/utils";
|
||||||
import { uniq } from "lodash";
|
import { uniq } from "lodash";
|
||||||
import PerformerResult, { PerformerOperation } from "./PerformerResult";
|
import { blobToBase64 } from "base64-blob";
|
||||||
import StudioResult, { StudioOperation } from "./StudioResult";
|
import { stringToGender } from "src/utils/gender";
|
||||||
import { IStashBoxScene } from "./utils";
|
|
||||||
import { useTagScene } from "./taggerService";
|
|
||||||
import { TagOperation } from "./constants";
|
|
||||||
import { OptionalField } from "./IncludeButton";
|
import { OptionalField } from "./IncludeButton";
|
||||||
|
import { IScrapedScene, TaggerStateContext } from "./context";
|
||||||
|
import { OperationButton } from "../Shared/OperationButton";
|
||||||
|
import { SceneTaggerModalsState } from "./sceneTaggerModals";
|
||||||
|
import PerformerResult from "./PerformerResult";
|
||||||
|
import StudioResult from "./StudioResult";
|
||||||
|
|
||||||
const getDurationStatus = (
|
const getDurationStatus = (
|
||||||
scene: IStashBoxScene,
|
scene: IScrapedScene,
|
||||||
stashDuration: number | undefined | null
|
stashDuration: number | undefined | null
|
||||||
) => {
|
) => {
|
||||||
if (!stashDuration) return "";
|
if (!stashDuration) return "";
|
||||||
|
|
||||||
const durations = scene.fingerprints
|
const durations =
|
||||||
.map((f) => f.duration)
|
scene.fingerprints
|
||||||
.map((d) => Math.abs(d - stashDuration));
|
?.map((f) => f.duration)
|
||||||
|
.map((d) => Math.abs(d - stashDuration)) ?? [];
|
||||||
|
|
||||||
|
const sceneDuration = scene.duration ?? 0;
|
||||||
|
|
||||||
|
if (!sceneDuration && durations.length === 0) return "";
|
||||||
|
|
||||||
const matchCount = durations.filter((duration) => duration <= 5).length;
|
const matchCount = durations.filter((duration) => duration <= 5).length;
|
||||||
|
|
||||||
let match;
|
let match;
|
||||||
@@ -39,7 +47,7 @@ const getDurationStatus = (
|
|||||||
values={{ matchCount, durationsLength: durations.length }}
|
values={{ matchCount, durationsLength: durations.length }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
else if (Math.abs(scene.duration - stashDuration) < 5)
|
else if (Math.abs(sceneDuration - stashDuration) < 5)
|
||||||
match = <FormattedMessage id="component_tagger.results.fp_matches" />;
|
match = <FormattedMessage id="component_tagger.results.fp_matches" />;
|
||||||
|
|
||||||
if (match)
|
if (match)
|
||||||
@@ -50,11 +58,8 @@ const getDurationStatus = (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!scene.duration && durations.length === 0)
|
|
||||||
return <FormattedMessage id="component_tagger.results.duration_unknown" />;
|
|
||||||
|
|
||||||
const minDiff = Math.min(
|
const minDiff = Math.min(
|
||||||
Math.abs(scene.duration - stashDuration),
|
Math.abs(sceneDuration - stashDuration),
|
||||||
...durations
|
...durations
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -66,13 +71,13 @@ const getDurationStatus = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFingerprintStatus = (
|
const getFingerprintStatus = (
|
||||||
scene: IStashBoxScene,
|
scene: IScrapedScene,
|
||||||
stashScene: GQL.SlimSceneDataFragment
|
stashScene: GQL.SlimSceneDataFragment
|
||||||
) => {
|
) => {
|
||||||
const checksumMatch = scene.fingerprints.some(
|
const checksumMatch = scene.fingerprints?.some(
|
||||||
(f) => f.hash === stashScene.checksum || f.hash === stashScene.oshash
|
(f) => f.hash === stashScene.checksum || f.hash === stashScene.oshash
|
||||||
);
|
);
|
||||||
const phashMatch = scene.fingerprints.some(
|
const phashMatch = scene.fingerprints?.some(
|
||||||
(f) => f.hash === stashScene.phash
|
(f) => f.hash === stashScene.phash
|
||||||
);
|
);
|
||||||
if (checksumMatch || phashMatch)
|
if (checksumMatch || phashMatch)
|
||||||
@@ -94,55 +99,60 @@ const getFingerprintStatus = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface IStashSearchResultProps {
|
interface IStashSearchResultProps {
|
||||||
scene: IStashBoxScene;
|
scene: IScrapedScene;
|
||||||
stashScene: GQL.SlimSceneDataFragment;
|
stashScene: GQL.SlimSceneDataFragment;
|
||||||
|
index: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
setActive: () => void;
|
|
||||||
showMales: boolean;
|
|
||||||
setScene: (scene: GQL.SlimSceneDataFragment) => void;
|
|
||||||
setCoverImage: boolean;
|
|
||||||
tagOperation: TagOperation;
|
|
||||||
setTags: boolean;
|
|
||||||
endpoint: string;
|
|
||||||
queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
|
|
||||||
createNewTag: (toCreate: GQL.ScrapedTag) => void;
|
|
||||||
excludedFields: Record<string, boolean>;
|
|
||||||
setExcludedFields: (v: Record<string, boolean>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IPerformerReducerAction {
|
|
||||||
id: string;
|
|
||||||
data: PerformerOperation;
|
|
||||||
}
|
|
||||||
|
|
||||||
const performerReducer = (
|
|
||||||
state: Record<string, PerformerOperation>,
|
|
||||||
action: IPerformerReducerAction
|
|
||||||
) => ({ ...state, [action.id]: action.data });
|
|
||||||
|
|
||||||
const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||||
scene,
|
scene,
|
||||||
stashScene,
|
stashScene,
|
||||||
|
index,
|
||||||
isActive,
|
isActive,
|
||||||
setActive,
|
|
||||||
showMales,
|
|
||||||
setScene,
|
|
||||||
setCoverImage,
|
|
||||||
tagOperation,
|
|
||||||
setTags,
|
|
||||||
endpoint,
|
|
||||||
queueFingerprintSubmission,
|
|
||||||
createNewTag,
|
|
||||||
excludedFields,
|
|
||||||
setExcludedFields,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
createNewTag,
|
||||||
|
createNewPerformer,
|
||||||
|
linkPerformer,
|
||||||
|
createNewStudio,
|
||||||
|
linkStudio,
|
||||||
|
resolveScene,
|
||||||
|
currentSource,
|
||||||
|
saveScene,
|
||||||
|
} = React.useContext(TaggerStateContext);
|
||||||
|
|
||||||
|
const performers = useMemo(
|
||||||
|
() =>
|
||||||
|
scene.performers?.filter((p) => {
|
||||||
|
if (!config.showMales) {
|
||||||
|
return (
|
||||||
|
!p.gender || stringToGender(p.gender, true) !== GQL.GenderEnum.Male
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}) ?? [],
|
||||||
|
[config, scene]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { createPerformerModal, createStudioModal } = React.useContext(
|
||||||
|
SceneTaggerModalsState
|
||||||
|
);
|
||||||
|
|
||||||
const getInitialTags = useCallback(() => {
|
const getInitialTags = useCallback(() => {
|
||||||
const stashSceneTags = stashScene.tags.map((t) => t.id);
|
const stashSceneTags = stashScene.tags.map((t) => t.id);
|
||||||
if (!setTags) {
|
if (!config.setTags) {
|
||||||
return stashSceneTags;
|
return stashSceneTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTags = scene.tags.filter((t) => t.id).map((t) => t.id!);
|
const { tagOperation } = config;
|
||||||
|
|
||||||
|
const newTags =
|
||||||
|
scene.tags?.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? [];
|
||||||
|
|
||||||
if (tagOperation === "overwrite") {
|
if (tagOperation === "overwrite") {
|
||||||
return newTags;
|
return newTags;
|
||||||
}
|
}
|
||||||
@@ -151,56 +161,65 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("unexpected tagOperation");
|
throw new Error("unexpected tagOperation");
|
||||||
}, [stashScene, tagOperation, scene, setTags]);
|
}, [stashScene, scene, config]);
|
||||||
|
|
||||||
const [studio, setStudio] = useState<StudioOperation>();
|
const getInitialPerformers = useCallback(() => {
|
||||||
const [performers, dispatch] = useReducer(performerReducer, {});
|
return performers.map((p) => p.stored_id ?? undefined);
|
||||||
const [tagIDs, setTagIDs] = useState<string[]>(getInitialTags());
|
}, [performers]);
|
||||||
const [saveState, setSaveState] = useState<string>("");
|
|
||||||
const [error, setError] = useState<{ message?: string; details?: string }>(
|
const getInitialStudio = useCallback(() => {
|
||||||
|
return scene.studio?.stored_id ?? stashScene.studio?.id;
|
||||||
|
}, [stashScene, scene]);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [excludedFields, setExcludedFields] = useState<Record<string, boolean>>(
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
const [tagIDs, setTagIDs] = useState<string[]>(getInitialTags());
|
||||||
|
|
||||||
const intl = useIntl();
|
// map of original performer to id
|
||||||
|
const [performerIDs, setPerformerIDs] = useState<(string | undefined)[]>(
|
||||||
|
getInitialPerformers()
|
||||||
|
);
|
||||||
|
|
||||||
|
const [studioID, setStudioID] = useState<string | undefined>(
|
||||||
|
getInitialStudio()
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTagIDs(getInitialTags());
|
setTagIDs(getInitialTags());
|
||||||
}, [setTags, tagOperation, getInitialTags]);
|
}, [getInitialTags]);
|
||||||
|
|
||||||
const tagScene = useTagScene(
|
useEffect(() => {
|
||||||
{
|
setPerformerIDs(getInitialPerformers());
|
||||||
tagOperation,
|
}, [getInitialPerformers]);
|
||||||
setCoverImage,
|
|
||||||
setTags,
|
|
||||||
},
|
|
||||||
setSaveState,
|
|
||||||
setError
|
|
||||||
);
|
|
||||||
|
|
||||||
function getExcludedFields() {
|
useEffect(() => {
|
||||||
return Object.keys(excludedFields).filter((f) => excludedFields[f]);
|
setStudioID(getInitialStudio());
|
||||||
}
|
}, [getInitialStudio]);
|
||||||
|
|
||||||
async function handleSave() {
|
useEffect(() => {
|
||||||
const updatedScene = await tagScene(
|
async function doResolveScene() {
|
||||||
stashScene,
|
try {
|
||||||
scene,
|
setLoading(true);
|
||||||
studio,
|
await resolveScene(stashScene.id, index, scene);
|
||||||
performers,
|
} finally {
|
||||||
tagIDs,
|
setLoading(false);
|
||||||
getExcludedFields(),
|
}
|
||||||
endpoint
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (updatedScene) setScene(updatedScene);
|
if (isActive && !loading && !scene.resolved) {
|
||||||
|
doResolveScene();
|
||||||
|
}
|
||||||
|
}, [isActive, loading, stashScene, index, resolveScene, scene]);
|
||||||
|
|
||||||
queueFingerprintSubmission(stashScene.id, endpoint);
|
const stashBoxURL = useMemo(() => {
|
||||||
}
|
if (currentSource?.stashboxEndpoint && scene.remote_site_id) {
|
||||||
|
const endpoint = currentSource.stashboxEndpoint;
|
||||||
const setPerformer = (
|
const endpointBase = endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||||
performerData: PerformerOperation,
|
return `${endpointBase}scenes/${scene.remote_site_id}`;
|
||||||
performerID: string
|
}
|
||||||
) => dispatch({ id: performerID, data: performerData });
|
}, [currentSource, scene]);
|
||||||
|
|
||||||
const setExcludedField = (name: string, value: boolean) =>
|
const setExcludedField = (name: string, value: boolean) =>
|
||||||
setExcludedFields({
|
setExcludedFields({
|
||||||
@@ -208,33 +227,102 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||||||
[name]: value,
|
[name]: value,
|
||||||
});
|
});
|
||||||
|
|
||||||
const classname = cx("row mx-0 mt-2 search-result", {
|
async function handleSave() {
|
||||||
"selected-result": isActive,
|
const excludedFieldList = Object.keys(excludedFields).filter(
|
||||||
});
|
(f) => excludedFields[f]
|
||||||
|
);
|
||||||
|
|
||||||
const sceneTitle = scene.url ? (
|
function resolveField<T>(field: string, stashField: T, remoteField: T) {
|
||||||
<a
|
if (excludedFieldList.includes(field)) {
|
||||||
href={scene.url}
|
return stashField;
|
||||||
target="_blank"
|
}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="scene-link"
|
|
||||||
>
|
|
||||||
<TruncatedText text={scene?.title} />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<TruncatedText text={scene?.title} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveEnabled =
|
return remoteField;
|
||||||
Object.keys(performers ?? []).length ===
|
}
|
||||||
scene.performers.filter((p) => p.gender !== "MALE" || showMales).length &&
|
|
||||||
Object.keys(performers ?? []).every((id) => performers?.[id].type) &&
|
|
||||||
saveState === "";
|
|
||||||
|
|
||||||
const endpointBase = endpoint.match(/https?:\/\/.*?\//)?.[0];
|
let imgData;
|
||||||
const stashBoxURL = endpointBase
|
if (!excludedFields.cover_image && config.setCoverImage) {
|
||||||
? `${endpointBase}scenes/${scene.stash_id}`
|
const imgurl = scene.image;
|
||||||
: "";
|
if (imgurl) {
|
||||||
|
const img = await fetch(imgurl, {
|
||||||
|
mode: "cors",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (img.status === 200) {
|
||||||
|
const blob = await img.blob();
|
||||||
|
// Sanity check on image size since bad images will fail
|
||||||
|
if (blob.size > 10000) imgData = await blobToBase64(blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPerformerIDs = performerIDs.filter(
|
||||||
|
(id) => id !== undefined
|
||||||
|
) as string[];
|
||||||
|
|
||||||
|
const sceneCreateInput: GQL.SceneUpdateInput = {
|
||||||
|
id: stashScene.id ?? "",
|
||||||
|
title: resolveField("title", stashScene.title, scene.title),
|
||||||
|
details: resolveField("details", stashScene.details, scene.details),
|
||||||
|
date: resolveField("date", stashScene.date, scene.date),
|
||||||
|
performer_ids:
|
||||||
|
filteredPerformerIDs.length === 0
|
||||||
|
? stashScene.performers.map((p) => p.id)
|
||||||
|
: filteredPerformerIDs,
|
||||||
|
studio_id: studioID,
|
||||||
|
cover_image: resolveField("cover_image", undefined, imgData),
|
||||||
|
url: resolveField("url", stashScene.url, scene.url),
|
||||||
|
tag_ids: tagIDs,
|
||||||
|
stash_ids: stashScene.stash_ids ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const includeStashID = !excludedFieldList.includes("stash_ids");
|
||||||
|
|
||||||
|
if (
|
||||||
|
includeStashID &&
|
||||||
|
currentSource?.stashboxEndpoint &&
|
||||||
|
scene.remote_site_id
|
||||||
|
) {
|
||||||
|
sceneCreateInput.stash_ids = [
|
||||||
|
...(stashScene?.stash_ids
|
||||||
|
.map((s) => {
|
||||||
|
return {
|
||||||
|
endpoint: s.endpoint,
|
||||||
|
stash_id: s.stash_id,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((s) => s.endpoint !== currentSource.stashboxEndpoint) ?? []),
|
||||||
|
{
|
||||||
|
endpoint: currentSource.stashboxEndpoint,
|
||||||
|
stash_id: scene.remote_site_id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveScene(sceneCreateInput, includeStashID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function performerModalCallback(
|
||||||
|
toCreate?: GQL.PerformerCreateInput | undefined
|
||||||
|
) {
|
||||||
|
if (toCreate) {
|
||||||
|
createNewPerformer(toCreate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPerformerModal(t: GQL.ScrapedPerformer) {
|
||||||
|
createPerformerModal(t, performerModalCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function studioModalCallback(toCreate?: GQL.StudioCreateInput | undefined) {
|
||||||
|
if (toCreate) {
|
||||||
|
createNewStudio(toCreate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStudioModal(t: GQL.ScrapedStudio) {
|
||||||
|
createStudioModal(t, studioModalCallback);
|
||||||
|
}
|
||||||
|
|
||||||
// constants to get around dot-notation eslint rule
|
// constants to get around dot-notation eslint rule
|
||||||
const fields = {
|
const fields = {
|
||||||
@@ -243,175 +331,331 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||||||
date: "date",
|
date: "date",
|
||||||
url: "url",
|
url: "url",
|
||||||
details: "details",
|
details: "details",
|
||||||
|
studio: "studio",
|
||||||
|
stash_ids: "stash_ids",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const maybeRenderCoverImage = () => {
|
||||||
|
if (scene.image) {
|
||||||
|
return (
|
||||||
|
<div className="scene-image-container">
|
||||||
|
<OptionalField
|
||||||
|
disabled={!config.setCoverImage}
|
||||||
|
exclude={
|
||||||
|
excludedFields[fields.cover_image] || !config.setCoverImage
|
||||||
|
}
|
||||||
|
setExclude={(v) => setExcludedField(fields.cover_image, v)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={scene.image}
|
||||||
|
alt=""
|
||||||
|
className="align-self-center scene-image"
|
||||||
|
/>
|
||||||
|
</OptionalField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTitle = () => {
|
||||||
|
if (!scene.title) {
|
||||||
|
return (
|
||||||
|
<h4 className="text-muted">
|
||||||
|
<FormattedMessage id="component_tagger.results.unnamed" />
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sceneTitleEl = scene.url ? (
|
||||||
|
<a
|
||||||
|
href={scene.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="scene-link"
|
||||||
|
>
|
||||||
|
<TruncatedText text={scene.title} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<TruncatedText text={scene.title} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h4>
|
||||||
|
<OptionalField
|
||||||
|
exclude={excludedFields[fields.title]}
|
||||||
|
setExclude={(v) => setExcludedField(fields.title, v)}
|
||||||
|
>
|
||||||
|
{sceneTitleEl}
|
||||||
|
</OptionalField>
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderStudioDate() {
|
||||||
|
const text =
|
||||||
|
scene.studio && scene.date
|
||||||
|
? `${scene.studio.name} • ${scene.date}`
|
||||||
|
: `${scene.studio?.name ?? scene.date ?? ""}`;
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
return <h5>{text}</h5>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPerformerList = () => {
|
||||||
|
if (scene.performers?.length) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{intl.formatMessage(
|
||||||
|
{ id: "countables.performers" },
|
||||||
|
{ count: scene?.performers?.length }
|
||||||
|
)}
|
||||||
|
: {scene?.performers?.map((p) => p.name).join(", ")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeRenderDateField = () => {
|
||||||
|
if (isActive && scene.date) {
|
||||||
|
return (
|
||||||
|
<h5>
|
||||||
|
<OptionalField
|
||||||
|
exclude={excludedFields[fields.date]}
|
||||||
|
setExclude={(v) => setExcludedField(fields.date, v)}
|
||||||
|
>
|
||||||
|
{scene.date}
|
||||||
|
</OptionalField>
|
||||||
|
</h5>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeRenderURL = () => {
|
||||||
|
if (scene.url) {
|
||||||
|
return (
|
||||||
|
<div className="scene-details">
|
||||||
|
<OptionalField
|
||||||
|
exclude={excludedFields[fields.url]}
|
||||||
|
setExclude={(v) => setExcludedField(fields.url, v)}
|
||||||
|
>
|
||||||
|
<a href={scene.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{scene.url}
|
||||||
|
</a>
|
||||||
|
</OptionalField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeRenderDetails = () => {
|
||||||
|
if (scene.details) {
|
||||||
|
return (
|
||||||
|
<div className="scene-details">
|
||||||
|
<OptionalField
|
||||||
|
exclude={excludedFields[fields.details]}
|
||||||
|
setExclude={(v) => setExcludedField(fields.details, v)}
|
||||||
|
>
|
||||||
|
<TruncatedText text={scene.details ?? ""} lineCount={3} />
|
||||||
|
</OptionalField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeRenderStashBoxID = () => {
|
||||||
|
if (scene.remote_site_id && stashBoxURL) {
|
||||||
|
return (
|
||||||
|
<div className="scene-details">
|
||||||
|
<OptionalField
|
||||||
|
exclude={excludedFields[fields.stash_ids]}
|
||||||
|
setExclude={(v) => setExcludedField(fields.stash_ids, v)}
|
||||||
|
>
|
||||||
|
<a href={stashBoxURL} target="_blank" rel="noopener noreferrer">
|
||||||
|
{scene.remote_site_id}
|
||||||
|
</a>
|
||||||
|
</OptionalField>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeRenderStudioField = () => {
|
||||||
|
if (scene.studio) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<StudioResult
|
||||||
|
studio={scene.studio}
|
||||||
|
selectedID={studioID}
|
||||||
|
setSelectedID={(id) => setStudioID(id)}
|
||||||
|
onCreate={() => showStudioModal(scene.studio!)}
|
||||||
|
endpoint={currentSource?.stashboxEndpoint}
|
||||||
|
onLink={async () => {
|
||||||
|
await linkStudio(scene.studio!, studioID!);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function setPerformerID(performerIndex: number, id: string | undefined) {
|
||||||
|
const newPerformerIDs = [...performerIDs];
|
||||||
|
newPerformerIDs[performerIndex] = id;
|
||||||
|
setPerformerIDs(newPerformerIDs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPerformerField = () => (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div>
|
||||||
|
<Form.Group controlId="performers">
|
||||||
|
{performers.map((performer, performerIndex) => (
|
||||||
|
<PerformerResult
|
||||||
|
performer={performer}
|
||||||
|
selectedID={performerIDs[performerIndex]}
|
||||||
|
setSelectedID={(id) => setPerformerID(performerIndex, id)}
|
||||||
|
onCreate={() => showPerformerModal(performer)}
|
||||||
|
onLink={async () => {
|
||||||
|
await linkPerformer(performer, performerIDs[performerIndex]!);
|
||||||
|
}}
|
||||||
|
endpoint={currentSource?.stashboxEndpoint}
|
||||||
|
key={`${performer.name ?? performer.remote_site_id ?? ""}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTagsField = () => (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div>
|
||||||
|
<Form.Group controlId="tags" as={Row}>
|
||||||
|
{FormUtils.renderLabel({
|
||||||
|
title: `${intl.formatMessage({ id: "tags" })}:`,
|
||||||
|
})}
|
||||||
|
<Col sm={9} xl={12}>
|
||||||
|
<TagSelect
|
||||||
|
isMulti
|
||||||
|
onSelect={(items) => {
|
||||||
|
setTagIDs(items.map((i) => i.id));
|
||||||
|
}}
|
||||||
|
ids={tagIDs}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
{scene.tags
|
||||||
|
?.filter((t) => !t.stored_id)
|
||||||
|
.map((t) => (
|
||||||
|
<Badge
|
||||||
|
className="tag-item"
|
||||||
|
variant="secondary"
|
||||||
|
key={t.name}
|
||||||
|
onClick={() => {
|
||||||
|
createNewTag(t);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
<Button className="minimal ml-2">
|
||||||
|
<Icon className="fa-fw" icon="plus" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingIndicator card />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
<>
|
||||||
<li
|
<div className={isActive ? "col-lg-6" : ""}>
|
||||||
className={`${classname} ${isActive && "active"}`}
|
<div className="row mx-0">
|
||||||
key={scene.stash_id}
|
{maybeRenderCoverImage()}
|
||||||
onClick={() => !isActive && setActive()}
|
|
||||||
>
|
|
||||||
<div className="col-lg-6">
|
|
||||||
<div className="row">
|
|
||||||
<div className="scene-image-container">
|
|
||||||
<OptionalField
|
|
||||||
exclude={excludedFields[fields.cover_image] || !setCoverImage}
|
|
||||||
disabled={!setCoverImage}
|
|
||||||
setExclude={(v) => setExcludedField(fields.cover_image, v)}
|
|
||||||
>
|
|
||||||
<a href={stashBoxURL} target="_blank" rel="noopener noreferrer">
|
|
||||||
<img
|
|
||||||
src={scene.images[0]}
|
|
||||||
alt=""
|
|
||||||
className="align-self-center scene-image"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</OptionalField>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex flex-column justify-content-center scene-metadata">
|
<div className="d-flex flex-column justify-content-center scene-metadata">
|
||||||
<h4>
|
{renderTitle()}
|
||||||
<OptionalField
|
|
||||||
exclude={excludedFields[fields.title]}
|
|
||||||
setExclude={(v) => setExcludedField(fields.title, v)}
|
|
||||||
>
|
|
||||||
{sceneTitle}
|
|
||||||
</OptionalField>
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
{!isActive && (
|
{!isActive && (
|
||||||
<>
|
<>
|
||||||
<h5>
|
{renderStudioDate()}
|
||||||
{scene?.studio?.name} • {scene?.date}
|
{renderPerformerList()}
|
||||||
</h5>
|
|
||||||
<div>
|
|
||||||
{intl.formatMessage(
|
|
||||||
{ id: "countables.performers" },
|
|
||||||
{ count: scene?.performers?.length }
|
|
||||||
)}
|
|
||||||
: {scene?.performers?.map((p) => p.name).join(", ")}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isActive && scene.date && (
|
{maybeRenderDateField()}
|
||||||
<h5>
|
|
||||||
<OptionalField
|
|
||||||
exclude={excludedFields[fields.date]}
|
|
||||||
setExclude={(v) => setExcludedField(fields.date, v)}
|
|
||||||
>
|
|
||||||
{scene.date}
|
|
||||||
</OptionalField>
|
|
||||||
</h5>
|
|
||||||
)}
|
|
||||||
{getDurationStatus(scene, stashScene.file?.duration)}
|
{getDurationStatus(scene, stashScene.file?.duration)}
|
||||||
{getFingerprintStatus(scene, stashScene)}
|
{getFingerprintStatus(scene, stashScene)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="d-flex flex-column">
|
<div className="d-flex flex-column">
|
||||||
{scene.url && (
|
{maybeRenderStashBoxID()}
|
||||||
<div className="scene-details">
|
{maybeRenderURL()}
|
||||||
<OptionalField
|
{maybeRenderDetails()}
|
||||||
exclude={excludedFields[fields.url]}
|
|
||||||
setExclude={(v) => setExcludedField(fields.url, v)}
|
|
||||||
>
|
|
||||||
<a href={scene.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{scene.url}
|
|
||||||
</a>
|
|
||||||
</OptionalField>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{scene.details && (
|
|
||||||
<div className="scene-details">
|
|
||||||
<OptionalField
|
|
||||||
exclude={excludedFields[fields.details]}
|
|
||||||
setExclude={(v) => setExcludedField(fields.details, v)}
|
|
||||||
>
|
|
||||||
<TruncatedText text={scene.details ?? ""} lineCount={3} />
|
|
||||||
</OptionalField>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="col-lg-6">
|
<div className="col-lg-6">
|
||||||
<StudioResult studio={scene.studio} setStudio={setStudio} />
|
{maybeRenderStudioField()}
|
||||||
{scene.performers
|
{renderPerformerField()}
|
||||||
.filter((p) => p.gender !== "MALE" || showMales)
|
{renderTagsField()}
|
||||||
.map((performer) => (
|
|
||||||
<PerformerResult
|
|
||||||
performer={performer}
|
|
||||||
setPerformer={(data: PerformerOperation) =>
|
|
||||||
setPerformer(data, performer.stash_id)
|
|
||||||
}
|
|
||||||
key={`${scene.stash_id}${performer.stash_id}`}
|
|
||||||
endpoint={endpoint}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<div className="mt-2">
|
|
||||||
<div>
|
|
||||||
<Form.Group controlId="tags" as={Row}>
|
|
||||||
{FormUtils.renderLabel({
|
|
||||||
title: `${intl.formatMessage({ id: "tags" })}:`,
|
|
||||||
})}
|
|
||||||
<Col sm={9} xl={12}>
|
|
||||||
<TagSelect
|
|
||||||
isDisabled={!setTags}
|
|
||||||
isMulti
|
|
||||||
onSelect={(items) => {
|
|
||||||
setTagIDs(items.map((i) => i.id));
|
|
||||||
}}
|
|
||||||
ids={tagIDs}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Form.Group>
|
|
||||||
</div>
|
|
||||||
{setTags &&
|
|
||||||
scene.tags
|
|
||||||
.filter((t) => !t.id)
|
|
||||||
.map((t) => (
|
|
||||||
<Badge
|
|
||||||
className="tag-item"
|
|
||||||
variant="secondary"
|
|
||||||
key={t.name}
|
|
||||||
onClick={() => {
|
|
||||||
createNewTag(t);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.name}
|
|
||||||
<Button className="minimal ml-2">
|
|
||||||
<Icon className="fa-fw" icon="plus" />
|
|
||||||
</Button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="row no-gutters mt-2 align-items-center justify-content-end">
|
<div className="row no-gutters mt-2 align-items-center justify-content-end">
|
||||||
{error.message && (
|
<OperationButton operation={handleSave}>
|
||||||
<strong className="mt-1 mr-2 text-danger text-right">
|
<FormattedMessage id="actions.save" />
|
||||||
<abbr title={error.details} className="mr-2">
|
</OperationButton>
|
||||||
Error:
|
|
||||||
</abbr>
|
|
||||||
{error.message}
|
|
||||||
</strong>
|
|
||||||
)}
|
|
||||||
{saveState && (
|
|
||||||
<strong className="col-4 mt-1 mr-2 text-right">
|
|
||||||
{saveState}
|
|
||||||
</strong>
|
|
||||||
)}
|
|
||||||
<Button onClick={handleSave} disabled={!saveEnabled}>
|
|
||||||
{saveState ? (
|
|
||||||
<LoadingIndicator inline small message="" />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage id="actions.save" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ISceneSearchResults {
|
||||||
|
target: GQL.SlimSceneDataFragment;
|
||||||
|
scenes: GQL.ScrapedSceneDataFragment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneSearchResults: React.FC<ISceneSearchResults> = ({
|
||||||
|
target,
|
||||||
|
scenes,
|
||||||
|
}) => {
|
||||||
|
const [selectedResult, setSelectedResult] = useState<number | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scenes) {
|
||||||
|
setSelectedResult(undefined);
|
||||||
|
}
|
||||||
|
}, [scenes]);
|
||||||
|
|
||||||
|
function getClassName(i: number) {
|
||||||
|
return cx("row mx-0 mt-2 search-result", {
|
||||||
|
"selected-result active": i === selectedResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="pl-0 mt-3 mb-0">
|
||||||
|
{scenes.map((s, i) => (
|
||||||
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key
|
||||||
|
<li
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={i}
|
||||||
|
onClick={() => setSelectedResult(i)}
|
||||||
|
className={getClassName(i)}
|
||||||
|
>
|
||||||
|
<StashSearchResult
|
||||||
|
index={i}
|
||||||
|
isActive={i === selectedResult}
|
||||||
|
scene={s}
|
||||||
|
stashScene={target}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
114
ui/v2.5/src/components/Tagger/StudioModal.tsx
Normal file
114
ui/v2.5/src/components/Tagger/StudioModal.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useContext } from "react";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
import { IconName } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
|
||||||
|
import { Icon, Modal, TruncatedText } from "src/components/Shared";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { TaggerStateContext } from "./context";
|
||||||
|
|
||||||
|
interface IStudioModalProps {
|
||||||
|
studio: GQL.ScrapedSceneStudioDataFragment;
|
||||||
|
modalVisible: boolean;
|
||||||
|
closeModal: () => void;
|
||||||
|
handleStudioCreate: (input: GQL.StudioCreateInput) => void;
|
||||||
|
header: string;
|
||||||
|
icon: IconName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudioModal: React.FC<IStudioModalProps> = ({
|
||||||
|
modalVisible,
|
||||||
|
studio,
|
||||||
|
handleStudioCreate,
|
||||||
|
closeModal,
|
||||||
|
header,
|
||||||
|
icon,
|
||||||
|
}) => {
|
||||||
|
const { currentSource } = useContext(TaggerStateContext);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
if (!studio.name) {
|
||||||
|
throw new Error("studio name must set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const studioData: GQL.StudioCreateInput = {
|
||||||
|
name: studio.name ?? "",
|
||||||
|
url: studio.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
// stashid handling code
|
||||||
|
const remoteSiteID = studio.remote_site_id;
|
||||||
|
if (remoteSiteID && currentSource?.stashboxEndpoint) {
|
||||||
|
studioData.stash_ids = [
|
||||||
|
{
|
||||||
|
endpoint: currentSource.stashboxEndpoint,
|
||||||
|
stash_id: remoteSiteID,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
handleStudioCreate(studioData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderField = (
|
||||||
|
id: string,
|
||||||
|
text: string | null | undefined,
|
||||||
|
truncate: boolean = true
|
||||||
|
) =>
|
||||||
|
text && (
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<div className="col-5 studio-create-modal-field" key={id}>
|
||||||
|
<strong>
|
||||||
|
<FormattedMessage id={id} />:
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{truncate ? (
|
||||||
|
<TruncatedText className="col-7" text={text} />
|
||||||
|
) : (
|
||||||
|
<span className="col-7">{text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = currentSource?.stashboxEndpoint?.match(/https?:\/\/.*?\//)?.[0];
|
||||||
|
const link = base ? `${base}studios/${studio.remote_site_id}` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
show={modalVisible}
|
||||||
|
accept={{
|
||||||
|
text: intl.formatMessage({ id: "actions.save" }),
|
||||||
|
onClick: onSave,
|
||||||
|
}}
|
||||||
|
onHide={() => closeModal()}
|
||||||
|
cancel={{ onClick: () => closeModal(), variant: "secondary" }}
|
||||||
|
icon={icon}
|
||||||
|
header={header}
|
||||||
|
>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
|
{renderField("name", studio.name)}
|
||||||
|
{renderField("url", studio.url)}
|
||||||
|
{link && (
|
||||||
|
<h6 className="mt-2">
|
||||||
|
<a href={link} target="_blank" rel="noopener noreferrer">
|
||||||
|
Stash-Box Source
|
||||||
|
<Icon icon="external-link-alt" className="ml-2" />
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TODO - add image */}
|
||||||
|
{/* <div className="row">
|
||||||
|
<strong className="col-2">Logo:</strong>
|
||||||
|
<span className="col-10">
|
||||||
|
<img src={studio?.image ?? ""} alt="" />
|
||||||
|
</span>
|
||||||
|
</div> */}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudioModal;
|
||||||
@@ -1,122 +1,75 @@
|
|||||||
import React, { useEffect, useState, Dispatch, SetStateAction } from "react";
|
import React from "react";
|
||||||
import { Button, ButtonGroup } from "react-bootstrap";
|
import { Button, ButtonGroup } from "react-bootstrap";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage } from "react-intl";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
|
|
||||||
import { Modal, StudioSelect } from "src/components/Shared";
|
import { Icon, StudioSelect } from "src/components/Shared";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { ValidTypes } from "src/components/Shared/Select";
|
import { ValidTypes } from "src/components/Shared/Select";
|
||||||
import { IStashBoxStudio } from "./utils";
|
|
||||||
import { OptionalField } from "./IncludeButton";
|
|
||||||
|
|
||||||
export type StudioOperation =
|
import { OptionalField } from "./IncludeButton";
|
||||||
| { type: "create"; data: IStashBoxStudio }
|
import { OperationButton } from "../Shared/OperationButton";
|
||||||
| { type: "update"; data: GQL.SlimStudioDataFragment }
|
|
||||||
| { type: "existing"; data: GQL.StudioDataFragment }
|
|
||||||
| { type: "skip" };
|
|
||||||
|
|
||||||
interface IStudioResultProps {
|
interface IStudioResultProps {
|
||||||
studio: IStashBoxStudio | null;
|
studio: GQL.ScrapedStudio;
|
||||||
setStudio: Dispatch<SetStateAction<StudioOperation | undefined>>;
|
selectedID: string | undefined;
|
||||||
|
setSelectedID: (id: string | undefined) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onLink?: () => Promise<void>;
|
||||||
|
endpoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
|
const StudioResult: React.FC<IStudioResultProps> = ({
|
||||||
const intl = useIntl();
|
studio,
|
||||||
const [selectedStudio, setSelectedStudio] = useState<string | null>();
|
selectedID,
|
||||||
const [modalVisible, showModal] = useState(false);
|
setSelectedID,
|
||||||
const [selectedSource, setSelectedSource] = useState<
|
onCreate,
|
||||||
"create" | "existing" | "skip" | undefined
|
onLink,
|
||||||
>();
|
endpoint,
|
||||||
const { data: studioData } = GQL.useFindStudioQuery({
|
}) => {
|
||||||
variables: { id: studio?.id ?? "" },
|
const { data: studioData, loading: stashLoading } = GQL.useFindStudioQuery({
|
||||||
skip: !studio?.id,
|
variables: { id: studio.stored_id ?? "" },
|
||||||
});
|
skip: !studio.stored_id,
|
||||||
const {
|
|
||||||
data: stashIDData,
|
|
||||||
loading: loadingStashID,
|
|
||||||
} = GQL.useFindStudiosQuery({
|
|
||||||
variables: {
|
|
||||||
studio_filter: {
|
|
||||||
stash_id: {
|
|
||||||
value: studio?.stash_id ?? "no-stashid",
|
|
||||||
modifier: GQL.CriterionModifier.Equals,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const matchedStudio = studioData?.findStudio;
|
||||||
if (stashIDData?.findStudios.studios?.[0])
|
const matchedStashID = matchedStudio?.stash_ids.some(
|
||||||
setStudio({
|
(stashID) => stashID.endpoint === endpoint && stashID.stash_id
|
||||||
type: "existing",
|
);
|
||||||
data: stashIDData.findStudios.studios[0],
|
|
||||||
});
|
|
||||||
else if (studioData?.findStudio) {
|
|
||||||
setSelectedSource("existing");
|
|
||||||
setSelectedStudio(studioData.findStudio.id);
|
|
||||||
setStudio({
|
|
||||||
type: "update",
|
|
||||||
data: studioData.findStudio,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [stashIDData, studioData]);
|
|
||||||
|
|
||||||
const handleStudioSelect = (newStudio: ValidTypes[]) => {
|
const handleSelect = (studios: ValidTypes[]) => {
|
||||||
if (newStudio.length) {
|
if (studios.length) {
|
||||||
setSelectedSource("existing");
|
setSelectedID(studios[0].id);
|
||||||
setSelectedStudio(newStudio[0].id);
|
|
||||||
setStudio({
|
|
||||||
type: "update",
|
|
||||||
data: newStudio[0] as GQL.SlimStudioDataFragment,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setSelectedSource(undefined);
|
setSelectedID(undefined);
|
||||||
setSelectedStudio(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStudioCreate = () => {
|
const handleSkip = () => {
|
||||||
if (!studio) return;
|
setSelectedID(undefined);
|
||||||
setSelectedSource("create");
|
|
||||||
setStudio({
|
|
||||||
type: "create",
|
|
||||||
data: studio,
|
|
||||||
});
|
|
||||||
showModal(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStudioSkip = () => {
|
if (stashLoading) return <div>Loading studio</div>;
|
||||||
setSelectedSource("skip");
|
|
||||||
setStudio({ type: "skip" });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loadingStashID) return <div>Loading studio</div>;
|
if (matchedStudio && matchedStashID) {
|
||||||
|
|
||||||
if (stashIDData?.findStudios.studios.length) {
|
|
||||||
return (
|
return (
|
||||||
<div className="row no-gutters my-2">
|
<div className="row no-gutters my-2">
|
||||||
<div className="entity-name">
|
<div className="entity-name">
|
||||||
<FormattedMessage
|
<FormattedMessage id="countables.studios" values={{ count: 1 }} />:
|
||||||
id="countables.studios"
|
<b className="ml-2">{studio.name}</b>
|
||||||
values={{ count: stashIDData?.findStudios.studios.length }}
|
|
||||||
/>
|
|
||||||
:<b className="ml-2">{studio?.name}</b>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-auto">
|
<span className="ml-auto">
|
||||||
<OptionalField
|
<OptionalField
|
||||||
exclude={selectedSource === "skip"}
|
exclude={selectedID === undefined}
|
||||||
setExclude={(v) =>
|
setExclude={(v) =>
|
||||||
v ? handleStudioSkip() : setSelectedSource("existing")
|
v ? handleSkip() : setSelectedID(matchedStudio.id)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="mr-2">
|
<span className="mr-2">
|
||||||
<FormattedMessage id="component_tagger.verb_matched" />:
|
<FormattedMessage id="component_tagger.verb_matched" />:
|
||||||
</span>
|
</span>
|
||||||
<b className="col-3 text-right">
|
<b className="col-3 text-right">{matchedStudio.name}</b>
|
||||||
{stashIDData.findStudios.studios[0].name}
|
|
||||||
</b>
|
|
||||||
</div>
|
</div>
|
||||||
</OptionalField>
|
</OptionalField>
|
||||||
</span>
|
</span>
|
||||||
@@ -124,60 +77,48 @@ const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeRenderLinkButton() {
|
||||||
|
if (endpoint && onLink) {
|
||||||
|
return (
|
||||||
|
<OperationButton
|
||||||
|
variant="secondary"
|
||||||
|
disabled={selectedID === undefined}
|
||||||
|
operation={onLink}
|
||||||
|
hideChildrenWhenLoading
|
||||||
|
>
|
||||||
|
<Icon icon="save" />
|
||||||
|
</OperationButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSource = !selectedID ? "skip" : "existing";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row no-gutters align-items-center mt-2">
|
<div className="row no-gutters align-items-center mt-2">
|
||||||
<Modal
|
|
||||||
show={modalVisible}
|
|
||||||
accept={{
|
|
||||||
text: intl.formatMessage({ id: "actions.save" }),
|
|
||||||
onClick: handleStudioCreate,
|
|
||||||
}}
|
|
||||||
cancel={{ onClick: () => showModal(false), variant: "secondary" }}
|
|
||||||
>
|
|
||||||
<div className="row">
|
|
||||||
<strong className="col-2">
|
|
||||||
<FormattedMessage id="name" />:
|
|
||||||
</strong>
|
|
||||||
<span className="col-10">{studio?.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="row">
|
|
||||||
<strong className="col-2">
|
|
||||||
<FormattedMessage id="url" />:
|
|
||||||
</strong>
|
|
||||||
<span className="col-10">{studio?.url ?? ""}</span>
|
|
||||||
</div>
|
|
||||||
<div className="row">
|
|
||||||
<strong className="col-2">Logo:</strong>
|
|
||||||
<span className="col-10">
|
|
||||||
<img src={studio?.image ?? ""} alt="" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<div className="entity-name">
|
<div className="entity-name">
|
||||||
<FormattedMessage id="studios" />:<b className="ml-2">{studio?.name}</b>
|
<FormattedMessage id="countables.studios" values={{ count: 1 }} />:
|
||||||
|
<b className="ml-2">{studio.name}</b>
|
||||||
</div>
|
</div>
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<Button
|
<Button variant="secondary" onClick={() => onCreate()}>
|
||||||
variant={selectedSource === "create" ? "primary" : "secondary"}
|
|
||||||
onClick={() => showModal(true)}
|
|
||||||
>
|
|
||||||
<FormattedMessage id="actions.create" />
|
<FormattedMessage id="actions.create" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={selectedSource === "skip" ? "primary" : "secondary"}
|
variant={selectedSource === "skip" ? "primary" : "secondary"}
|
||||||
onClick={() => handleStudioSkip()}
|
onClick={() => handleSkip()}
|
||||||
>
|
>
|
||||||
<FormattedMessage id="actions.skip" />
|
<FormattedMessage id="actions.skip" />
|
||||||
</Button>
|
</Button>
|
||||||
<StudioSelect
|
<StudioSelect
|
||||||
ids={selectedStudio ? [selectedStudio] : []}
|
ids={selectedID ? [selectedID] : []}
|
||||||
onSelect={handleStudioSelect}
|
onSelect={handleSelect}
|
||||||
className={cx("studio-select", {
|
className={cx("studio-select", {
|
||||||
"studio-select-active": selectedSource === "existing",
|
"studio-select-active": selectedSource === "existing",
|
||||||
})}
|
})}
|
||||||
isClearable={false}
|
isClearable={false}
|
||||||
/>
|
/>
|
||||||
|
{maybeRenderLinkButton()}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useContext, useState } from "react";
|
||||||
import { Button } from "react-bootstrap";
|
|
||||||
import { FormattedMessage } from "react-intl";
|
|
||||||
import { HashLink } from "react-router-hash-link";
|
|
||||||
import { useLocalForage } from "src/hooks";
|
|
||||||
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { LoadingIndicator } from "src/components/Shared";
|
|
||||||
import { stashBoxSceneQuery } from "src/core/StashService";
|
|
||||||
import { Manual } from "src/components/Help/Manual";
|
|
||||||
|
|
||||||
import { SceneQueue } from "src/models/sceneQueue";
|
import { SceneQueue } from "src/models/sceneQueue";
|
||||||
import { ConfigurationContext } from "src/hooks/Config";
|
import { Button, Form } from "react-bootstrap";
|
||||||
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
|
import { Icon, LoadingIndicator } from "src/components/Shared";
|
||||||
|
import { OperationButton } from "src/components/Shared/OperationButton";
|
||||||
|
import { TaggerStateContext } from "./context";
|
||||||
import Config from "./Config";
|
import Config from "./Config";
|
||||||
import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "./constants";
|
import { TaggerScene } from "./TaggerScene";
|
||||||
import { TaggerList } from "./TaggerList";
|
import { SceneTaggerModals } from "./sceneTaggerModals";
|
||||||
|
import { SceneSearchResults } from "./StashSearchResult";
|
||||||
|
|
||||||
interface ITaggerProps {
|
interface ITaggerProps {
|
||||||
scenes: GQL.SlimSceneDataFragment[];
|
scenes: GQL.SlimSceneDataFragment[];
|
||||||
@@ -21,161 +17,220 @@ interface ITaggerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
||||||
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
|
const {
|
||||||
const [{ data: config }, setConfig] = useLocalForage<ITaggerConfig>(
|
sources,
|
||||||
LOCAL_FORAGE_KEY,
|
setCurrentSource,
|
||||||
initialConfig
|
currentSource,
|
||||||
);
|
doSceneQuery,
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
doSceneFragmentScrape,
|
||||||
const [showManual, setShowManual] = useState(false);
|
doMultiSceneFragmentScrape,
|
||||||
|
stopMultiScrape,
|
||||||
const clearSubmissionQueue = (endpoint: string) => {
|
searchResults,
|
||||||
if (!config) return;
|
loading,
|
||||||
|
loadingMulti,
|
||||||
setConfig({
|
multiError,
|
||||||
...config,
|
|
||||||
fingerprintQueue: {
|
|
||||||
...config.fingerprintQueue,
|
|
||||||
[endpoint]: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const [
|
|
||||||
submitFingerprints,
|
submitFingerprints,
|
||||||
{ loading: submittingFingerprints },
|
pendingFingerprints,
|
||||||
] = GQL.useSubmitStashBoxFingerprintsMutation();
|
} = useContext(TaggerStateContext);
|
||||||
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [hideUnmatched, setHideUnmatched] = useState(false);
|
||||||
|
|
||||||
const handleFingerprintSubmission = (endpoint: string) => {
|
const intl = useIntl();
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
return submitFingerprints({
|
function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) {
|
||||||
variables: {
|
return queue
|
||||||
input: {
|
? queue.makeLink(scene.id, { sceneIndex: index })
|
||||||
stash_box_index: getEndpointIndex(endpoint),
|
: `/scenes/${scene.id}`;
|
||||||
scene_ids: config?.fingerprintQueue[endpoint],
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
}).then(() => {
|
|
||||||
clearSubmissionQueue(endpoint);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!config) return <LoadingIndicator />;
|
function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value));
|
||||||
|
}
|
||||||
|
|
||||||
const savedEndpointIndex =
|
function renderSourceSelector() {
|
||||||
stashConfig?.general.stashBoxes.findIndex(
|
|
||||||
(s) => s.endpoint === config.selectedEndpoint
|
|
||||||
) ?? -1;
|
|
||||||
const selectedEndpointIndex =
|
|
||||||
savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length
|
|
||||||
? 0
|
|
||||||
: savedEndpointIndex;
|
|
||||||
const selectedEndpoint =
|
|
||||||
stashConfig?.general.stashBoxes[selectedEndpointIndex];
|
|
||||||
|
|
||||||
function getEndpointIndex(endpoint: string) {
|
|
||||||
return (
|
return (
|
||||||
stashConfig?.general.stashBoxes.findIndex(
|
<Form.Group controlId="scraper">
|
||||||
(s) => s.endpoint === endpoint
|
<Form.Label>
|
||||||
) ?? -1
|
<FormattedMessage id="component_tagger.config.source" />
|
||||||
|
</Form.Label>
|
||||||
|
<div>
|
||||||
|
<Form.Control
|
||||||
|
as="select"
|
||||||
|
value={currentSource?.id}
|
||||||
|
className="input-control"
|
||||||
|
disabled={loading || !sources.length}
|
||||||
|
onChange={handleSourceSelect}
|
||||||
|
>
|
||||||
|
{!sources.length && <option>No scraper sources</option>}
|
||||||
|
{sources.map((i) => (
|
||||||
|
<option value={i.id} key={i.id}>
|
||||||
|
{i.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Form.Control>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doBoxSearch(searchVal: string) {
|
function renderConfigButton() {
|
||||||
return (await stashBoxSceneQuery(searchVal, selectedEndpointIndex)).data;
|
return (
|
||||||
|
<div className="ml-2">
|
||||||
|
<Button onClick={() => setShowConfig(!showConfig)}>
|
||||||
|
<Icon className="fa-fw" icon="cog" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueFingerprintSubmission = (sceneId: string, endpoint: string) => {
|
function renderScenes() {
|
||||||
if (!config) return;
|
const filteredScenes = !hideUnmatched
|
||||||
setConfig({
|
? scenes
|
||||||
...config,
|
: scenes.filter((s) => searchResults[s.id]?.results?.length);
|
||||||
fingerprintQueue: {
|
|
||||||
...config.fingerprintQueue,
|
|
||||||
[endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getQueue = (endpoint: string) => {
|
return filteredScenes.map((scene, index) => {
|
||||||
if (!config) return [];
|
const sceneLink = generateSceneLink(scene, index);
|
||||||
return config.fingerprintQueue[endpoint] ?? [];
|
let errorMessage: string | undefined;
|
||||||
};
|
const searchResult = searchResults[scene.id];
|
||||||
|
if (searchResult?.error) {
|
||||||
|
errorMessage = searchResult.error;
|
||||||
|
} else if (searchResult && searchResult.results?.length === 0) {
|
||||||
|
errorMessage = intl.formatMessage({
|
||||||
|
id: "component_tagger.results.match_failed_no_result",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const fingerprintQueue = {
|
return (
|
||||||
queueFingerprintSubmission,
|
<TaggerScene
|
||||||
getQueue,
|
key={scene.id}
|
||||||
submitFingerprints: handleFingerprintSubmission,
|
loading={loading}
|
||||||
submittingFingerprints,
|
scene={scene}
|
||||||
};
|
url={sceneLink}
|
||||||
|
errorMessage={errorMessage}
|
||||||
return (
|
doSceneQuery={
|
||||||
<>
|
currentSource?.supportSceneQuery
|
||||||
<Manual
|
? async (v) => {
|
||||||
show={showManual}
|
await doSceneQuery(scene.id, v);
|
||||||
onClose={() => setShowManual(false)}
|
|
||||||
defaultActiveTab="Tagger.md"
|
|
||||||
/>
|
|
||||||
<div className="tagger-container mx-md-auto">
|
|
||||||
{selectedEndpointIndex !== -1 && selectedEndpoint ? (
|
|
||||||
<>
|
|
||||||
<div className="row mb-2 no-gutters">
|
|
||||||
<Button onClick={() => setShowConfig(!showConfig)} variant="link">
|
|
||||||
<FormattedMessage
|
|
||||||
id="component_tagger.verb_toggle_config"
|
|
||||||
values={{
|
|
||||||
toggle: (
|
|
||||||
<FormattedMessage
|
|
||||||
id={`actions.${showConfig ? "hide" : "show"}`}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
configuration: <FormattedMessage id="configuration" />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
onClick={() => setShowManual(true)}
|
|
||||||
title="Help"
|
|
||||||
variant="link"
|
|
||||||
>
|
|
||||||
<FormattedMessage id="help" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Config config={config} setConfig={setConfig} show={showConfig} />
|
|
||||||
<TaggerList
|
|
||||||
scenes={scenes}
|
|
||||||
queue={queue}
|
|
||||||
config={config}
|
|
||||||
selectedEndpoint={{
|
|
||||||
endpoint: selectedEndpoint.endpoint,
|
|
||||||
index: selectedEndpointIndex,
|
|
||||||
}}
|
|
||||||
queryScene={doBoxSearch}
|
|
||||||
fingerprintQueue={fingerprintQueue}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="my-4">
|
|
||||||
<h3 className="text-center mt-4">
|
|
||||||
To use the scene tagger a stash-box instance needs to be
|
|
||||||
configured.
|
|
||||||
</h3>
|
|
||||||
<h5 className="text-center">
|
|
||||||
Please see{" "}
|
|
||||||
<HashLink
|
|
||||||
to="/settings?tab=configuration#stashbox"
|
|
||||||
scroll={(el) =>
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
||||||
}
|
}
|
||||||
>
|
: undefined
|
||||||
Settings.
|
}
|
||||||
</HashLink>
|
scrapeSceneFragment={
|
||||||
</h5>
|
currentSource?.supportSceneFragment
|
||||||
</div>
|
? async () => {
|
||||||
|
await doSceneFragmentScrape(scene.id);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{searchResult && searchResult.results?.length ? (
|
||||||
|
<SceneSearchResults scenes={searchResult.results} target={scene} />
|
||||||
|
) : undefined}
|
||||||
|
</TaggerScene>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleHideUnmatchedScenes = () => {
|
||||||
|
setHideUnmatched(!hideUnmatched);
|
||||||
|
};
|
||||||
|
|
||||||
|
function maybeRenderShowHideUnmatchedButton() {
|
||||||
|
if (Object.keys(searchResults).length) {
|
||||||
|
return (
|
||||||
|
<Button onClick={toggleHideUnmatchedScenes}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="component_tagger.verb_toggle_unmatched"
|
||||||
|
values={{
|
||||||
|
toggle: (
|
||||||
|
<FormattedMessage
|
||||||
|
id={`actions.${!hideUnmatched ? "hide" : "show"}`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderSubmitFingerprintsButton() {
|
||||||
|
if (pendingFingerprints.length) {
|
||||||
|
return (
|
||||||
|
<OperationButton
|
||||||
|
className="ml-1"
|
||||||
|
operation={submitFingerprints}
|
||||||
|
disabled={loading || loadingMulti}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<FormattedMessage
|
||||||
|
id="component_tagger.verb_submit_fp"
|
||||||
|
values={{ fpCount: pendingFingerprints.length }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</OperationButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFragmentScrapeButton() {
|
||||||
|
if (!currentSource?.supportSceneFragment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingMulti) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="ml-1"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
stopMultiScrape();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoadingIndicator message="" inline small />
|
||||||
|
<span className="ml-2">
|
||||||
|
{intl.formatMessage({ id: "actions.stop" })}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ml-1">
|
||||||
|
<OperationButton
|
||||||
|
disabled={loading}
|
||||||
|
operation={async () => {
|
||||||
|
await doMultiSceneFragmentScrape(scenes.map((s) => s.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: "component_tagger.verb_scrape_all" })}
|
||||||
|
</OperationButton>
|
||||||
|
{multiError && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<b className="text-danger">{multiError}</b>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SceneTaggerModals>
|
||||||
|
<div className="tagger-container mx-md-auto">
|
||||||
|
<div className="tagger-container-header">
|
||||||
|
<div className="d-flex justify-content-between align-items-center flex-wrap">
|
||||||
|
<div className="w-auto">{renderSourceSelector()}</div>
|
||||||
|
<div className="d-flex">
|
||||||
|
{maybeRenderShowHideUnmatchedButton()}
|
||||||
|
{maybeRenderSubmitFingerprintsButton()}
|
||||||
|
{renderFragmentScrapeButton()}
|
||||||
|
{renderConfigButton()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Config show={showConfig} />
|
||||||
|
</div>
|
||||||
|
<div>{renderScenes()}</div>
|
||||||
|
</div>
|
||||||
|
</SceneTaggerModals>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,338 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
|
||||||
import { Button, Card } from "react-bootstrap";
|
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
|
||||||
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
|
||||||
import { LoadingIndicator } from "src/components/Shared";
|
|
||||||
import { stashBoxSceneBatchQuery, useTagCreate } from "src/core/StashService";
|
|
||||||
|
|
||||||
import { SceneQueue } from "src/models/sceneQueue";
|
|
||||||
import { useToast } from "src/hooks";
|
|
||||||
import { ITaggerConfig } from "./constants";
|
|
||||||
import { selectScenes, IStashBoxScene } from "./utils";
|
|
||||||
import { TaggerScene } from "./TaggerScene";
|
|
||||||
|
|
||||||
interface IFingerprintQueue {
|
|
||||||
getQueue: (endpoint: string) => string[];
|
|
||||||
queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
|
|
||||||
submitFingerprints: (endpoint: string) => Promise<void> | undefined;
|
|
||||||
submittingFingerprints: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ITaggerListProps {
|
|
||||||
scenes: GQL.SlimSceneDataFragment[];
|
|
||||||
queue?: SceneQueue;
|
|
||||||
selectedEndpoint: { endpoint: string; index: number };
|
|
||||||
config: ITaggerConfig;
|
|
||||||
queryScene: (searchVal: string) => Promise<GQL.ScrapeSingleSceneQuery>;
|
|
||||||
fingerprintQueue: IFingerprintQueue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caches fingerprint lookups between page renders
|
|
||||||
let fingerprintCache: Record<string, IStashBoxScene[]> = {};
|
|
||||||
|
|
||||||
function fingerprintSearchResults(
|
|
||||||
scenes: GQL.SlimSceneDataFragment[],
|
|
||||||
fingerprints: Record<string, IStashBoxScene[]>
|
|
||||||
) {
|
|
||||||
const ret: Record<string, IStashBoxScene[]> = {};
|
|
||||||
|
|
||||||
if (Object.keys(fingerprints).length === 0) {
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
scenes.forEach((s) => {
|
|
||||||
ret[s.id] = fingerprints[s.id];
|
|
||||||
});
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TaggerList: React.FC<ITaggerListProps> = ({
|
|
||||||
scenes,
|
|
||||||
queue,
|
|
||||||
selectedEndpoint,
|
|
||||||
config,
|
|
||||||
queryScene,
|
|
||||||
fingerprintQueue,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const Toast = useToast();
|
|
||||||
const [createTag] = useTagCreate();
|
|
||||||
|
|
||||||
const [fingerprintError, setFingerprintError] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const inputForm = useRef<HTMLFormElement>(null);
|
|
||||||
|
|
||||||
const [searchErrors, setSearchErrors] = useState<
|
|
||||||
Record<string, string | undefined>
|
|
||||||
>({});
|
|
||||||
const [taggedScenes, setTaggedScenes] = useState<
|
|
||||||
Record<string, Partial<GQL.SlimSceneDataFragment>>
|
|
||||||
>({});
|
|
||||||
const [loadingFingerprints, setLoadingFingerprints] = useState(false);
|
|
||||||
const [fingerprints, setFingerprints] = useState<
|
|
||||||
Record<string, IStashBoxScene[]>
|
|
||||||
>(fingerprintCache);
|
|
||||||
const [searchResults, setSearchResults] = useState<
|
|
||||||
Record<string, IStashBoxScene[]>
|
|
||||||
>(fingerprintSearchResults(scenes, fingerprints));
|
|
||||||
const [hideUnmatched, setHideUnmatched] = useState(false);
|
|
||||||
const queuedFingerprints = fingerprintQueue.getQueue(
|
|
||||||
selectedEndpoint.endpoint
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputForm?.current?.reset();
|
|
||||||
}, [config.mode, config.blacklist]);
|
|
||||||
|
|
||||||
function clearSceneSearchResult(sceneID: string) {
|
|
||||||
// remove sceneID results from the results object
|
|
||||||
const { [sceneID]: _removedResult, ...newSearchResults } = searchResults;
|
|
||||||
const { [sceneID]: _removedError, ...newSearchErrors } = searchErrors;
|
|
||||||
setSearchResults(newSearchResults);
|
|
||||||
setSearchErrors(newSearchErrors);
|
|
||||||
}
|
|
||||||
|
|
||||||
const doSceneQuery = (sceneID: string, searchVal: string) => {
|
|
||||||
clearSceneSearchResult(sceneID);
|
|
||||||
|
|
||||||
queryScene(searchVal)
|
|
||||||
.then((queryData) => {
|
|
||||||
const s = selectScenes(queryData.scrapeSingleScene);
|
|
||||||
setSearchResults({
|
|
||||||
...searchResults,
|
|
||||||
[sceneID]: s,
|
|
||||||
});
|
|
||||||
setSearchErrors({
|
|
||||||
...searchErrors,
|
|
||||||
[sceneID]: undefined,
|
|
||||||
});
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLoading(false);
|
|
||||||
// Destructure to remove existing result
|
|
||||||
const { [sceneID]: unassign, ...results } = searchResults;
|
|
||||||
setSearchResults(results);
|
|
||||||
setSearchErrors({
|
|
||||||
...searchErrors,
|
|
||||||
[sceneID]: "Network Error",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFingerprintSubmission = () => {
|
|
||||||
fingerprintQueue.submitFingerprints(selectedEndpoint.endpoint);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTaggedScene = (scene: Partial<GQL.SlimSceneDataFragment>) => {
|
|
||||||
setTaggedScenes({
|
|
||||||
...taggedScenes,
|
|
||||||
[scene.id as string]: scene,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFingerprintSearch = async () => {
|
|
||||||
setLoadingFingerprints(true);
|
|
||||||
|
|
||||||
setSearchErrors({});
|
|
||||||
setSearchResults({});
|
|
||||||
|
|
||||||
const newFingerprints = { ...fingerprints };
|
|
||||||
|
|
||||||
const filteredScenes = scenes.filter((s) => s.stash_ids.length === 0);
|
|
||||||
const sceneIDs = filteredScenes.map((s) => s.id);
|
|
||||||
|
|
||||||
const results = await stashBoxSceneBatchQuery(
|
|
||||||
sceneIDs,
|
|
||||||
selectedEndpoint.index
|
|
||||||
).catch(() => {
|
|
||||||
setLoadingFingerprints(false);
|
|
||||||
setFingerprintError("Network Error");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!results) return;
|
|
||||||
|
|
||||||
// clear search errors
|
|
||||||
setSearchErrors({});
|
|
||||||
|
|
||||||
sceneIDs.forEach((sceneID, index) => {
|
|
||||||
newFingerprints[sceneID] = selectScenes(
|
|
||||||
results.data.scrapeMultiScenes[index]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const newSearchResults = fingerprintSearchResults(scenes, newFingerprints);
|
|
||||||
setSearchResults(newSearchResults);
|
|
||||||
|
|
||||||
setFingerprints(newFingerprints);
|
|
||||||
fingerprintCache = newFingerprints;
|
|
||||||
setLoadingFingerprints(false);
|
|
||||||
setFingerprintError("");
|
|
||||||
};
|
|
||||||
|
|
||||||
async function createNewTag(toCreate: GQL.ScrapedTag) {
|
|
||||||
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
|
|
||||||
try {
|
|
||||||
const result = await createTag({
|
|
||||||
variables: {
|
|
||||||
input: tagInput,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const tagID = result.data?.tagCreate?.id;
|
|
||||||
|
|
||||||
const newSearchResults = { ...searchResults };
|
|
||||||
|
|
||||||
// add the id to the existing search results
|
|
||||||
Object.keys(newSearchResults).forEach((k) => {
|
|
||||||
const searchResult = searchResults[k];
|
|
||||||
newSearchResults[k] = searchResult.map((r) => {
|
|
||||||
return {
|
|
||||||
...r,
|
|
||||||
tags: r.tags.map((t) => {
|
|
||||||
if (t.name === toCreate.name) {
|
|
||||||
return {
|
|
||||||
...t,
|
|
||||||
id: tagID,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return t;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setSearchResults(newSearchResults);
|
|
||||||
|
|
||||||
Toast.success({
|
|
||||||
content: (
|
|
||||||
<span>
|
|
||||||
Created tag: <b>{toCreate.name}</b>
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
Toast.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canFingerprintSearch = () =>
|
|
||||||
scenes.some(
|
|
||||||
(s) => s.stash_ids.length === 0 && fingerprints[s.id] === undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const getFingerprintCount = () => {
|
|
||||||
return scenes.filter(
|
|
||||||
(s) => s.stash_ids.length === 0 && fingerprints[s.id]?.length > 0
|
|
||||||
).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFingerprintCountMessage = () => {
|
|
||||||
const count = getFingerprintCount();
|
|
||||||
return intl.formatMessage(
|
|
||||||
{ id: "component_tagger.results.fp_found" },
|
|
||||||
{ fpCount: count }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleHideUnmatchedScenes = () => {
|
|
||||||
setHideUnmatched(!hideUnmatched);
|
|
||||||
};
|
|
||||||
|
|
||||||
function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) {
|
|
||||||
return queue
|
|
||||||
? queue.makeLink(scene.id, { sceneIndex: index })
|
|
||||||
: `/scenes/${scene.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderScenes = () =>
|
|
||||||
scenes.map((scene, index) => {
|
|
||||||
const sceneLink = generateSceneLink(scene, index);
|
|
||||||
const searchResult = {
|
|
||||||
results: searchResults[scene.id],
|
|
||||||
error: searchErrors[scene.id],
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TaggerScene
|
|
||||||
key={scene.id}
|
|
||||||
config={config}
|
|
||||||
endpoint={selectedEndpoint.endpoint}
|
|
||||||
queueFingerprintSubmission={
|
|
||||||
fingerprintQueue.queueFingerprintSubmission
|
|
||||||
}
|
|
||||||
scene={scene}
|
|
||||||
url={sceneLink}
|
|
||||||
hideUnmatched={hideUnmatched}
|
|
||||||
loading={loading}
|
|
||||||
taggedScene={taggedScenes[scene.id]}
|
|
||||||
doSceneQuery={(queryString) => doSceneQuery(scene.id, queryString)}
|
|
||||||
tagScene={handleTaggedScene}
|
|
||||||
searchResult={searchResult}
|
|
||||||
createNewTag={createNewTag}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="tagger-table">
|
|
||||||
<div className="tagger-table-header d-flex flex-nowrap align-items-center">
|
|
||||||
{/* TODO - sources select goes here */}
|
|
||||||
<b className="ml-auto mr-2 text-danger">{fingerprintError}</b>
|
|
||||||
<div className="mr-2">
|
|
||||||
{(getFingerprintCount() > 0 || hideUnmatched) && (
|
|
||||||
<Button onClick={toggleHideUnmatchedScenes}>
|
|
||||||
<FormattedMessage
|
|
||||||
id="component_tagger.verb_toggle_unmatched"
|
|
||||||
values={{
|
|
||||||
toggle: (
|
|
||||||
<FormattedMessage
|
|
||||||
id={`actions.${!hideUnmatched ? "hide" : "show"}`}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mr-2">
|
|
||||||
{queuedFingerprints.length > 0 && (
|
|
||||||
<Button
|
|
||||||
onClick={handleFingerprintSubmission}
|
|
||||||
disabled={fingerprintQueue.submittingFingerprints}
|
|
||||||
>
|
|
||||||
{fingerprintQueue.submittingFingerprints ? (
|
|
||||||
<LoadingIndicator message="" inline small />
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
<FormattedMessage
|
|
||||||
id="component_tagger.verb_submit_fp"
|
|
||||||
values={{ fpCount: queuedFingerprints.length }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleFingerprintSearch}
|
|
||||||
disabled={loadingFingerprints}
|
|
||||||
>
|
|
||||||
{canFingerprintSearch() && (
|
|
||||||
<span>
|
|
||||||
{intl.formatMessage({ id: "component_tagger.verb_match_fp" })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!canFingerprintSearch() && getFingerprintCountMessage()}
|
|
||||||
{loadingFingerprints && <LoadingIndicator message="" inline small />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<form ref={inputForm}>{renderScenes()}</form>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import React, { useRef, useState } from "react";
|
import React, { useState, useContext, PropsWithChildren } from "react";
|
||||||
import { Button, Collapse, Form, InputGroup } from "react-bootstrap";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { FormattedMessage } from "react-intl";
|
|
||||||
import { ScenePreview } from "src/components/Scenes/SceneCard";
|
|
||||||
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Icon, TagLink, TruncatedText } from "src/components/Shared";
|
import { Icon, TagLink, TruncatedText } from "src/components/Shared";
|
||||||
|
import { Button, Collapse, Form, InputGroup } from "react-bootstrap";
|
||||||
|
import { FormattedMessage } from "react-intl";
|
||||||
import { sortPerformers } from "src/core/performers";
|
import { sortPerformers } from "src/core/performers";
|
||||||
import StashSearchResult from "./StashSearchResult";
|
import { parsePath, prepareQueryString } from "src/components/Tagger/utils";
|
||||||
import { ITaggerConfig } from "./constants";
|
import { OperationButton } from "src/components/Shared/OperationButton";
|
||||||
import {
|
import { TaggerStateContext } from "./context";
|
||||||
parsePath,
|
import { ScenePreview } from "../Scenes/SceneCard";
|
||||||
IStashBoxScene,
|
|
||||||
sortScenesByDuration,
|
|
||||||
prepareQueryString,
|
|
||||||
} from "./utils";
|
|
||||||
|
|
||||||
interface ITaggerSceneDetails {
|
interface ITaggerSceneDetails {
|
||||||
scene: GQL.SlimSceneDataFragment;
|
scene: GQL.SlimSceneDataFragment;
|
||||||
@@ -25,7 +19,7 @@ const TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {
|
|||||||
const sorted = sortPerformers(scene.performers);
|
const sorted = sortPerformers(scene.performers);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scene-details">
|
<div className="original-scene-details">
|
||||||
<Collapse in={open}>
|
<Collapse in={open}>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col col-lg-6">
|
<div className="col col-lg-6">
|
||||||
@@ -78,55 +72,29 @@ const TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ISearchResult {
|
interface ITaggerScene {
|
||||||
results?: IStashBoxScene[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ITaggerScene {
|
|
||||||
scene: GQL.SlimSceneDataFragment;
|
scene: GQL.SlimSceneDataFragment;
|
||||||
url: string;
|
url: string;
|
||||||
config: ITaggerConfig;
|
errorMessage?: string;
|
||||||
searchResult?: ISearchResult;
|
doSceneQuery?: (queryString: string) => void;
|
||||||
hideUnmatched?: boolean;
|
scrapeSceneFragment?: (scene: GQL.SlimSceneDataFragment) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
doSceneQuery: (queryString: string) => void;
|
|
||||||
taggedScene?: Partial<GQL.SlimSceneDataFragment>;
|
|
||||||
tagScene: (scene: Partial<GQL.SlimSceneDataFragment>) => void;
|
|
||||||
endpoint: string;
|
|
||||||
queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
|
|
||||||
createNewTag: (toCreate: GQL.ScrapedTag) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaggerScene: React.FC<ITaggerScene> = ({
|
export const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({
|
||||||
scene,
|
scene,
|
||||||
url,
|
url,
|
||||||
config,
|
|
||||||
searchResult,
|
|
||||||
hideUnmatched,
|
|
||||||
loading,
|
loading,
|
||||||
doSceneQuery,
|
doSceneQuery,
|
||||||
taggedScene,
|
scrapeSceneFragment,
|
||||||
tagScene,
|
errorMessage,
|
||||||
endpoint,
|
children,
|
||||||
queueFingerprintSubmission,
|
|
||||||
createNewTag,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedResult, setSelectedResult] = useState<number>(0);
|
const { config } = useContext(TaggerStateContext);
|
||||||
const [excluded, setExcluded] = useState<Record<string, boolean>>({});
|
const [queryString, setQueryString] = useState<string>("");
|
||||||
|
const [queryLoading, setQueryLoading] = useState(false);
|
||||||
|
|
||||||
const queryString = useRef<string>("");
|
const { paths, file } = parsePath(scene.path);
|
||||||
|
|
||||||
const searchResults = searchResult?.results ?? [];
|
|
||||||
const searchError = searchResult?.error;
|
|
||||||
const emptyResults =
|
|
||||||
searchResult && searchResult.results && searchResult.results.length === 0;
|
|
||||||
|
|
||||||
const { paths, file, ext } = parsePath(scene.path);
|
|
||||||
const originalDir = scene.path.slice(
|
|
||||||
0,
|
|
||||||
scene.path.length - file.length - ext.length
|
|
||||||
);
|
|
||||||
const defaultQueryString = prepareQueryString(
|
const defaultQueryString = prepareQueryString(
|
||||||
scene,
|
scene,
|
||||||
paths,
|
paths,
|
||||||
@@ -135,72 +103,56 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
|
|||||||
config.blacklist
|
config.blacklist
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasStashIDs = scene.stash_ids.length > 0;
|
|
||||||
const width = scene.file.width ? scene.file.width : 0;
|
const width = scene.file.width ? scene.file.width : 0;
|
||||||
const height = scene.file.height ? scene.file.height : 0;
|
const height = scene.file.height ? scene.file.height : 0;
|
||||||
const isPortrait = height > width;
|
const isPortrait = height > width;
|
||||||
|
|
||||||
function renderMainContent() {
|
async function query() {
|
||||||
if (!taggedScene && hasStashIDs) {
|
if (!doSceneQuery) return;
|
||||||
return (
|
|
||||||
<div className="text-right">
|
|
||||||
<h5 className="text-bold">
|
|
||||||
<FormattedMessage id="component_tagger.results.match_failed_already_tagged" />
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!taggedScene && !hasStashIDs) {
|
try {
|
||||||
return (
|
setQueryLoading(true);
|
||||||
<InputGroup>
|
await doSceneQuery(queryString || defaultQueryString);
|
||||||
<InputGroup.Prepend>
|
} finally {
|
||||||
<InputGroup.Text>
|
setQueryLoading(false);
|
||||||
<FormattedMessage id="component_tagger.noun_query" />
|
|
||||||
</InputGroup.Text>
|
|
||||||
</InputGroup.Prepend>
|
|
||||||
<Form.Control
|
|
||||||
className="text-input"
|
|
||||||
defaultValue={queryString.current || defaultQueryString}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
queryString.current = e.currentTarget.value;
|
|
||||||
}}
|
|
||||||
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
|
|
||||||
e.key === "Enter" &&
|
|
||||||
doSceneQuery(queryString.current || defaultQueryString)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InputGroup.Append>
|
|
||||||
<Button
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() =>
|
|
||||||
doSceneQuery(queryString.current || defaultQueryString)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FormattedMessage id="actions.search" />
|
|
||||||
</Button>
|
|
||||||
</InputGroup.Append>
|
|
||||||
</InputGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (taggedScene) {
|
|
||||||
return (
|
|
||||||
<div className="d-flex flex-column text-right">
|
|
||||||
<h5>
|
|
||||||
<FormattedMessage id="component_tagger.results.match_success" />
|
|
||||||
</h5>
|
|
||||||
<h6>
|
|
||||||
<Link className="bold" to={url}>
|
|
||||||
{taggedScene.title}
|
|
||||||
</Link>
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSubContent() {
|
function renderQueryForm() {
|
||||||
|
if (!doSceneQuery) return;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroup.Prepend>
|
||||||
|
<InputGroup.Text>
|
||||||
|
<FormattedMessage id="component_tagger.noun_query" />
|
||||||
|
</InputGroup.Text>
|
||||||
|
</InputGroup.Prepend>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
value={queryString || defaultQueryString}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setQueryString(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
|
||||||
|
e.key === "Enter" && query()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputGroup.Append>
|
||||||
|
<OperationButton
|
||||||
|
disabled={loading}
|
||||||
|
operation={query}
|
||||||
|
loading={queryLoading}
|
||||||
|
setLoading={setQueryLoading}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.search" />
|
||||||
|
</OperationButton>
|
||||||
|
</InputGroup.Append>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderStashLinks() {
|
||||||
if (scene.stash_ids.length > 0) {
|
if (scene.stash_ids.length > 0) {
|
||||||
const stashLinks = scene.stash_ids.map((stashID) => {
|
const stashLinks = scene.stash_ids.map((stashID) => {
|
||||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||||
@@ -220,57 +172,11 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
|
|||||||
|
|
||||||
return link;
|
return link;
|
||||||
});
|
});
|
||||||
return <>{stashLinks}</>;
|
return <div className="mt-2 sub-content text-right">{stashLinks}</div>;
|
||||||
}
|
|
||||||
|
|
||||||
if (searchError) {
|
|
||||||
return <div className="text-danger font-weight-bold">{searchError}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emptyResults) {
|
|
||||||
return (
|
|
||||||
<div className="text-danger font-weight-bold">
|
|
||||||
<FormattedMessage id="component_tagger.results.match_failed_no_result" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSearchResult() {
|
return (
|
||||||
if (searchResults.length > 0 && !taggedScene) {
|
|
||||||
return (
|
|
||||||
<ul className="pl-0 mt-3 mb-0">
|
|
||||||
{sortScenesByDuration(
|
|
||||||
searchResults,
|
|
||||||
scene.file.duration ?? undefined
|
|
||||||
).map(
|
|
||||||
(sceneResult, i) =>
|
|
||||||
sceneResult && (
|
|
||||||
<StashSearchResult
|
|
||||||
key={sceneResult.stash_id}
|
|
||||||
showMales={config.showMales}
|
|
||||||
stashScene={scene}
|
|
||||||
scene={sceneResult}
|
|
||||||
isActive={selectedResult === i}
|
|
||||||
setActive={() => setSelectedResult(i)}
|
|
||||||
setCoverImage={config.setCoverImage}
|
|
||||||
tagOperation={config.tagOperation}
|
|
||||||
setTags={config.setTags}
|
|
||||||
setScene={tagScene}
|
|
||||||
endpoint={endpoint}
|
|
||||||
queueFingerprintSubmission={queueFingerprintSubmission}
|
|
||||||
createNewTag={createNewTag}
|
|
||||||
excludedFields={excluded}
|
|
||||||
setExcludedFields={(v) => setExcluded(v)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hideUnmatched && emptyResults ? null : (
|
|
||||||
<div key={scene.id} className="mt-3 search-item">
|
<div key={scene.id} className="mt-3 search-item">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col col-lg-6 overflow-hidden align-items-center d-flex flex-column flex-sm-row">
|
<div className="col col-lg-6 overflow-hidden align-items-center d-flex flex-column flex-sm-row">
|
||||||
@@ -285,19 +191,33 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Link to={url} className="scene-link overflow-hidden">
|
<Link to={url} className="scene-link overflow-hidden">
|
||||||
<TruncatedText
|
<TruncatedText text={scene.title ?? scene.path} lineCount={2} />
|
||||||
text={`${originalDir}\u200B${file}${ext}`}
|
|
||||||
lineCount={2}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6 my-1 align-self-center">
|
<div className="col-md-6 my-1">
|
||||||
{renderMainContent()}
|
<div>
|
||||||
<div className="sub-content text-right">{renderSubContent()}</div>
|
{renderQueryForm()}
|
||||||
|
{scrapeSceneFragment ? (
|
||||||
|
<div className="mt-2 text-right">
|
||||||
|
<OperationButton
|
||||||
|
disabled={loading}
|
||||||
|
operation={async () => {
|
||||||
|
await scrapeSceneFragment(scene);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="actions.scrape_scene_fragment" />
|
||||||
|
</OperationButton>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="text-danger font-weight-bold">{errorMessage}</div>
|
||||||
|
) : undefined}
|
||||||
|
{maybeRenderStashLinks()}
|
||||||
</div>
|
</div>
|
||||||
<TaggerSceneDetails scene={scene} />
|
<TaggerSceneDetails scene={scene} />
|
||||||
</div>
|
</div>
|
||||||
{renderSearchResult()}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
import { ScraperSourceInput } from "src/core/generated-graphql";
|
||||||
|
|
||||||
|
export const STASH_BOX_PREFIX = "stashbox:";
|
||||||
|
export const SCRAPER_PREFIX = "scraper:";
|
||||||
|
|
||||||
|
export interface ITaggerSource {
|
||||||
|
id: string;
|
||||||
|
stashboxEndpoint?: string;
|
||||||
|
sourceInput: ScraperSourceInput;
|
||||||
|
displayName: string;
|
||||||
|
supportSceneQuery?: boolean;
|
||||||
|
supportSceneFragment?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const LOCAL_FORAGE_KEY = "tagger";
|
export const LOCAL_FORAGE_KEY = "tagger";
|
||||||
export const DEFAULT_BLACKLIST = [
|
export const DEFAULT_BLACKLIST = [
|
||||||
"\\sXXX\\s",
|
"\\sXXX\\s",
|
||||||
|
|||||||
775
ui/v2.5/src/components/Tagger/context.tsx
Normal file
775
ui/v2.5/src/components/Tagger/context.tsx
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
initialConfig,
|
||||||
|
ITaggerConfig,
|
||||||
|
LOCAL_FORAGE_KEY,
|
||||||
|
} from "src/components/Tagger/constants";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import {
|
||||||
|
queryFindPerformer,
|
||||||
|
queryFindStudio,
|
||||||
|
queryScrapeScene,
|
||||||
|
queryScrapeSceneQuery,
|
||||||
|
queryScrapeSceneQueryFragment,
|
||||||
|
stashBoxSceneBatchQuery,
|
||||||
|
useListSceneScrapers,
|
||||||
|
usePerformerCreate,
|
||||||
|
usePerformerUpdate,
|
||||||
|
useSceneUpdate,
|
||||||
|
useStudioCreate,
|
||||||
|
useStudioUpdate,
|
||||||
|
useTagCreate,
|
||||||
|
} from "src/core/StashService";
|
||||||
|
import { useLocalForage, useToast } from "src/hooks";
|
||||||
|
import { ConfigurationContext } from "src/hooks/Config";
|
||||||
|
import { ITaggerSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants";
|
||||||
|
|
||||||
|
export interface ITaggerContextState {
|
||||||
|
config: ITaggerConfig;
|
||||||
|
setConfig: (c: ITaggerConfig) => void;
|
||||||
|
loading: boolean;
|
||||||
|
loadingMulti?: boolean;
|
||||||
|
multiError?: string;
|
||||||
|
sources: ITaggerSource[];
|
||||||
|
currentSource?: ITaggerSource;
|
||||||
|
searchResults: Record<string, ISceneQueryResult>;
|
||||||
|
setCurrentSource: (src?: ITaggerSource) => void;
|
||||||
|
doSceneQuery: (sceneID: string, searchStr: string) => Promise<void>;
|
||||||
|
doSceneFragmentScrape: (sceneID: string) => Promise<void>;
|
||||||
|
doMultiSceneFragmentScrape: (sceneIDs: string[]) => Promise<void>;
|
||||||
|
stopMultiScrape: () => void;
|
||||||
|
createNewTag: (toCreate: GQL.ScrapedTag) => Promise<string | undefined>;
|
||||||
|
createNewPerformer: (
|
||||||
|
toCreate: GQL.PerformerCreateInput
|
||||||
|
) => Promise<string | undefined>;
|
||||||
|
linkPerformer: (
|
||||||
|
performer: GQL.ScrapedPerformer,
|
||||||
|
performerID: string
|
||||||
|
) => Promise<void>;
|
||||||
|
createNewStudio: (
|
||||||
|
toCreate: GQL.StudioCreateInput
|
||||||
|
) => Promise<string | undefined>;
|
||||||
|
linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise<void>;
|
||||||
|
resolveScene: (
|
||||||
|
sceneID: string,
|
||||||
|
index: number,
|
||||||
|
scene: IScrapedScene
|
||||||
|
) => Promise<void>;
|
||||||
|
submitFingerprints: () => Promise<void>;
|
||||||
|
pendingFingerprints: string[];
|
||||||
|
saveScene: (
|
||||||
|
sceneCreateInput: GQL.SceneUpdateInput,
|
||||||
|
queueFingerprint: boolean
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyFn = () => {
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
const dummyValFn = () => {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TaggerStateContext = React.createContext<ITaggerContextState>({
|
||||||
|
config: initialConfig,
|
||||||
|
setConfig: () => {},
|
||||||
|
loading: false,
|
||||||
|
sources: [],
|
||||||
|
searchResults: {},
|
||||||
|
setCurrentSource: () => {},
|
||||||
|
doSceneQuery: dummyFn,
|
||||||
|
doSceneFragmentScrape: dummyFn,
|
||||||
|
doMultiSceneFragmentScrape: dummyFn,
|
||||||
|
stopMultiScrape: () => {},
|
||||||
|
createNewTag: dummyValFn,
|
||||||
|
createNewPerformer: dummyValFn,
|
||||||
|
linkPerformer: dummyFn,
|
||||||
|
createNewStudio: dummyValFn,
|
||||||
|
linkStudio: dummyFn,
|
||||||
|
resolveScene: dummyFn,
|
||||||
|
submitFingerprints: dummyFn,
|
||||||
|
pendingFingerprints: [],
|
||||||
|
saveScene: dummyFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean };
|
||||||
|
|
||||||
|
export interface ISceneQueryResult {
|
||||||
|
results?: IScrapedScene[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TaggerContext: React.FC = ({ children }) => {
|
||||||
|
const [{ data: config }, setConfig] = useLocalForage<ITaggerConfig>(
|
||||||
|
LOCAL_FORAGE_KEY,
|
||||||
|
initialConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMulti, setLoadingMulti] = useState(false);
|
||||||
|
const [sources, setSources] = useState<ITaggerSource[]>([]);
|
||||||
|
const [currentSource, setCurrentSource] = useState<ITaggerSource>();
|
||||||
|
const [multiError, setMultiError] = useState<string | undefined>();
|
||||||
|
const [searchResults, setSearchResults] = useState<
|
||||||
|
Record<string, ISceneQueryResult>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const stopping = useRef(false);
|
||||||
|
|
||||||
|
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
|
||||||
|
const Scrapers = useListSceneScrapers();
|
||||||
|
|
||||||
|
const Toast = useToast();
|
||||||
|
const [createTag] = useTagCreate();
|
||||||
|
const [createPerformer] = usePerformerCreate();
|
||||||
|
const [updatePerformer] = usePerformerUpdate();
|
||||||
|
const [createStudio] = useStudioCreate();
|
||||||
|
const [updateStudio] = useStudioUpdate();
|
||||||
|
const [updateScene] = useSceneUpdate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stashConfig || !Scrapers.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stashBoxes } = stashConfig.general;
|
||||||
|
const scrapers = Scrapers.data.listSceneScrapers;
|
||||||
|
|
||||||
|
const stashboxSources: ITaggerSource[] = stashBoxes.map((s, i) => ({
|
||||||
|
id: `${STASH_BOX_PREFIX}${i}`,
|
||||||
|
stashboxEndpoint: s.endpoint,
|
||||||
|
sourceInput: {
|
||||||
|
stash_box_index: i,
|
||||||
|
},
|
||||||
|
displayName: `stash-box: ${s.name}`,
|
||||||
|
supportSceneFragment: true,
|
||||||
|
supportSceneQuery: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// filter scraper sources such that only those that can query scrape or
|
||||||
|
// scrape via fragment are added
|
||||||
|
const scraperSources: ITaggerSource[] = scrapers
|
||||||
|
.filter((s) =>
|
||||||
|
s.scene?.supported_scrapes.some(
|
||||||
|
(t) => t === GQL.ScrapeType.Name || t === GQL.ScrapeType.Fragment
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((s) => ({
|
||||||
|
id: `${SCRAPER_PREFIX}${s.id}`,
|
||||||
|
sourceInput: {
|
||||||
|
scraper_id: s.id,
|
||||||
|
},
|
||||||
|
displayName: s.name,
|
||||||
|
supportSceneQuery: s.scene?.supported_scrapes.includes(
|
||||||
|
GQL.ScrapeType.Name
|
||||||
|
),
|
||||||
|
supportSceneFragment: s.scene?.supported_scrapes.includes(
|
||||||
|
GQL.ScrapeType.Fragment
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSources(stashboxSources.concat(scraperSources));
|
||||||
|
}, [Scrapers.data, stashConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sources.length && !currentSource) {
|
||||||
|
setCurrentSource(sources[0]);
|
||||||
|
}
|
||||||
|
}, [sources, currentSource]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchResults({});
|
||||||
|
}, [currentSource]);
|
||||||
|
|
||||||
|
function getPendingFingerprints() {
|
||||||
|
const endpoint = currentSource?.stashboxEndpoint;
|
||||||
|
if (!config || !endpoint) return [];
|
||||||
|
|
||||||
|
return config.fingerprintQueue[endpoint] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSubmissionQueue() {
|
||||||
|
const endpoint = currentSource?.stashboxEndpoint;
|
||||||
|
if (!config || !endpoint) return;
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
fingerprintQueue: {
|
||||||
|
...config.fingerprintQueue,
|
||||||
|
[endpoint]: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
submitFingerprintsMutation,
|
||||||
|
] = GQL.useSubmitStashBoxFingerprintsMutation();
|
||||||
|
|
||||||
|
async function submitFingerprints() {
|
||||||
|
const endpoint = currentSource?.stashboxEndpoint;
|
||||||
|
const stashBoxIndex =
|
||||||
|
currentSource?.sourceInput.stash_box_index ?? undefined;
|
||||||
|
|
||||||
|
if (!config || !endpoint || stashBoxIndex === undefined) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await submitFingerprintsMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
stash_box_index: stashBoxIndex,
|
||||||
|
scene_ids: config.fingerprintQueue[endpoint],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSubmissionQueue();
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueFingerprintSubmission(sceneId: string) {
|
||||||
|
const endpoint = currentSource?.stashboxEndpoint;
|
||||||
|
if (!config || !endpoint) return;
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
fingerprintQueue: {
|
||||||
|
...config.fingerprintQueue,
|
||||||
|
[endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSceneQuery(sceneID: string, searchVal: string) {
|
||||||
|
if (!currentSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const results = await queryScrapeSceneQuery(
|
||||||
|
currentSource.sourceInput,
|
||||||
|
searchVal
|
||||||
|
);
|
||||||
|
let newResult: ISceneQueryResult;
|
||||||
|
// scenes are already resolved if they come from stash-box
|
||||||
|
const resolved = currentSource.sourceInput.stash_box_index !== undefined;
|
||||||
|
|
||||||
|
if (results.error) {
|
||||||
|
newResult = { error: results.error.message };
|
||||||
|
} else if (results.errors) {
|
||||||
|
newResult = { error: results.errors.toString() };
|
||||||
|
} else {
|
||||||
|
newResult = {
|
||||||
|
results: results.data.scrapeSingleScene.map((r) => ({
|
||||||
|
...r,
|
||||||
|
resolved,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchResults({ ...searchResults, [sceneID]: newResult });
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sceneFragmentScrape(sceneID: string) {
|
||||||
|
if (!currentSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await queryScrapeScene(currentSource.sourceInput, sceneID);
|
||||||
|
let newResult: ISceneQueryResult;
|
||||||
|
|
||||||
|
if (results.error) {
|
||||||
|
newResult = { error: results.error.message };
|
||||||
|
} else if (results.errors) {
|
||||||
|
newResult = { error: results.errors.toString() };
|
||||||
|
} else {
|
||||||
|
newResult = {
|
||||||
|
results: results.data.scrapeSingleScene.map((r) => ({
|
||||||
|
...r,
|
||||||
|
// scenes are already resolved if they are scraped via fragment
|
||||||
|
resolved: true,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchResults((current) => {
|
||||||
|
return { ...current, [sceneID]: newResult };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSceneFragmentScrape(sceneID: string) {
|
||||||
|
if (!currentSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchResults((current) => {
|
||||||
|
const newResults = { ...current };
|
||||||
|
delete newResults[sceneID];
|
||||||
|
return newResults;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await sceneFragmentScrape(sceneID);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doMultiSceneFragmentScrape(sceneIDs: string[]) {
|
||||||
|
if (!currentSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchResults({});
|
||||||
|
|
||||||
|
try {
|
||||||
|
stopping.current = false;
|
||||||
|
setLoading(true);
|
||||||
|
setMultiError(undefined);
|
||||||
|
|
||||||
|
const stashBoxIndex =
|
||||||
|
currentSource.sourceInput.stash_box_index ?? undefined;
|
||||||
|
|
||||||
|
// if current source is stash-box, we can use the multi-scene
|
||||||
|
// interface
|
||||||
|
if (stashBoxIndex !== undefined) {
|
||||||
|
const results = await stashBoxSceneBatchQuery(sceneIDs, stashBoxIndex);
|
||||||
|
|
||||||
|
if (results.error) {
|
||||||
|
setMultiError(results.error.message);
|
||||||
|
} else if (results.errors) {
|
||||||
|
setMultiError(results.errors.toString());
|
||||||
|
} else {
|
||||||
|
const newSearchResults = { ...searchResults };
|
||||||
|
sceneIDs.forEach((sceneID, index) => {
|
||||||
|
const newResults = results.data.scrapeMultiScenes[index].map(
|
||||||
|
(r) => ({
|
||||||
|
...r,
|
||||||
|
resolved: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
newSearchResults[sceneID] = {
|
||||||
|
results: newResults,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setSearchResults(newSearchResults);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLoadingMulti(true);
|
||||||
|
|
||||||
|
// do singular calls
|
||||||
|
await sceneIDs.reduce(async (promise, id) => {
|
||||||
|
await promise;
|
||||||
|
if (!stopping.current) {
|
||||||
|
await sceneFragmentScrape(id);
|
||||||
|
}
|
||||||
|
}, Promise.resolve());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setLoadingMulti(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMultiScrape() {
|
||||||
|
stopping.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveScene(
|
||||||
|
sceneID: string,
|
||||||
|
index: number,
|
||||||
|
scene: IScrapedScene
|
||||||
|
) {
|
||||||
|
if (!currentSource || scene.resolved || !searchResults[sceneID].results) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sceneInput: GQL.ScrapedSceneInput = {
|
||||||
|
date: scene.date,
|
||||||
|
details: scene.details,
|
||||||
|
remote_site_id: scene.remote_site_id,
|
||||||
|
title: scene.title,
|
||||||
|
url: scene.url,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await queryScrapeSceneQueryFragment(
|
||||||
|
currentSource.sourceInput,
|
||||||
|
sceneInput
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.data.scrapeSingleScene.length) {
|
||||||
|
const resolvedScene = result.data.scrapeSingleScene[0];
|
||||||
|
|
||||||
|
// set the scene in the results and mark as resolved
|
||||||
|
const newResult = [...searchResults[sceneID].results!];
|
||||||
|
newResult[index] = { ...resolvedScene, resolved: true };
|
||||||
|
setSearchResults({
|
||||||
|
...searchResults,
|
||||||
|
[sceneID]: { ...searchResults[sceneID], results: newResult },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
|
||||||
|
const newResult = [...searchResults[sceneID].results!];
|
||||||
|
newResult[index] = { ...newResult[index], resolved: true };
|
||||||
|
setSearchResults({
|
||||||
|
...searchResults,
|
||||||
|
[sceneID]: { ...searchResults[sceneID], results: newResult },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearchResults(sceneID: string) {
|
||||||
|
setSearchResults((current) => {
|
||||||
|
const newSearchResults = { ...current };
|
||||||
|
delete newSearchResults[sceneID];
|
||||||
|
return newSearchResults;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScene(
|
||||||
|
sceneCreateInput: GQL.SceneUpdateInput,
|
||||||
|
queueFingerprint: boolean
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await updateScene({
|
||||||
|
variables: {
|
||||||
|
input: sceneCreateInput,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queueFingerprint) {
|
||||||
|
queueFingerprintSubmission(sceneCreateInput.id);
|
||||||
|
}
|
||||||
|
clearSearchResults(sceneCreateInput.id);
|
||||||
|
} catch (err) {
|
||||||
|
Toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapResults(fn: (r: IScrapedScene) => IScrapedScene) {
|
||||||
|
const newSearchResults = { ...searchResults };
|
||||||
|
|
||||||
|
Object.keys(newSearchResults).forEach((k) => {
|
||||||
|
const searchResult = searchResults[k];
|
||||||
|
if (!searchResult.results) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newSearchResults[k].results = searchResult.results.map(fn);
|
||||||
|
});
|
||||||
|
|
||||||
|
return newSearchResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewTag(toCreate: GQL.ScrapedTag) {
|
||||||
|
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
|
||||||
|
try {
|
||||||
|
const result = await createTag({
|
||||||
|
variables: {
|
||||||
|
input: tagInput,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagID = result.data?.tagCreate?.id;
|
||||||
|
|
||||||
|
const newSearchResults = mapResults((r) => {
|
||||||
|
if (!r.tags) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
tags: r.tags.map((t) => {
|
||||||
|
if (t.name === toCreate.name) {
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
stored_id: tagID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return t;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setSearchResults(newSearchResults);
|
||||||
|
|
||||||
|
Toast.success({
|
||||||
|
content: (
|
||||||
|
<span>
|
||||||
|
Created tag: <b>{toCreate.name}</b>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return tagID;
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewPerformer(toCreate: GQL.PerformerCreateInput) {
|
||||||
|
try {
|
||||||
|
const result = await createPerformer({
|
||||||
|
variables: {
|
||||||
|
input: toCreate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const performerID = result.data?.performerCreate?.id;
|
||||||
|
|
||||||
|
const newSearchResults = mapResults((r) => {
|
||||||
|
if (!r.performers) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
performers: r.performers.map((t) => {
|
||||||
|
if (t.name === toCreate.name) {
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
stored_id: performerID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return t;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setSearchResults(newSearchResults);
|
||||||
|
|
||||||
|
Toast.success({
|
||||||
|
content: (
|
||||||
|
<span>
|
||||||
|
Created performer: <b>{toCreate.name}</b>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return performerID;
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkPerformer(
|
||||||
|
performer: GQL.ScrapedPerformer,
|
||||||
|
performerID: string
|
||||||
|
) {
|
||||||
|
if (!performer.remote_site_id || !currentSource?.stashboxEndpoint) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryResult = await queryFindPerformer(performerID);
|
||||||
|
if (queryResult.data.findPerformer) {
|
||||||
|
const target = queryResult.data.findPerformer;
|
||||||
|
|
||||||
|
const stashIDs: GQL.StashIdInput[] = target.stash_ids.map((e) => {
|
||||||
|
return {
|
||||||
|
endpoint: e.endpoint,
|
||||||
|
stash_id: e.stash_id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
stashIDs.push({
|
||||||
|
stash_id: performer.remote_site_id,
|
||||||
|
endpoint: currentSource?.stashboxEndpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updatePerformer({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: performerID,
|
||||||
|
stash_ids: stashIDs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newSearchResults = mapResults((r) => {
|
||||||
|
if (!r.performers) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
performers: r.performers.map((p) => {
|
||||||
|
if (p.remote_site_id === performer.remote_site_id) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
stored_id: performerID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setSearchResults(newSearchResults);
|
||||||
|
|
||||||
|
Toast.success({
|
||||||
|
content: <span>Added stash-id to performer</span>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewStudio(toCreate: GQL.StudioCreateInput) {
|
||||||
|
try {
|
||||||
|
const result = await createStudio({
|
||||||
|
variables: {
|
||||||
|
input: toCreate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const studioID = result.data?.studioCreate?.id;
|
||||||
|
|
||||||
|
const newSearchResults = mapResults((r) => {
|
||||||
|
if (!r.studio) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
studio:
|
||||||
|
r.studio.name === toCreate.name
|
||||||
|
? {
|
||||||
|
...r.studio,
|
||||||
|
stored_id: studioID,
|
||||||
|
}
|
||||||
|
: r.studio,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setSearchResults(newSearchResults);
|
||||||
|
|
||||||
|
Toast.success({
|
||||||
|
content: (
|
||||||
|
<span>
|
||||||
|
Created studio: <b>{toCreate.name}</b>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return studioID;
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkStudio(studio: GQL.ScrapedStudio, studioID: string) {
|
||||||
|
if (!studio.remote_site_id || !currentSource?.stashboxEndpoint) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryResult = await queryFindStudio(studioID);
|
||||||
|
if (queryResult.data.findStudio) {
|
||||||
|
const target = queryResult.data.findStudio;
|
||||||
|
|
||||||
|
const stashIDs: GQL.StashIdInput[] = target.stash_ids.map((e) => {
|
||||||
|
return {
|
||||||
|
endpoint: e.endpoint,
|
||||||
|
stash_id: e.stash_id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
stashIDs.push({
|
||||||
|
stash_id: studio.remote_site_id,
|
||||||
|
endpoint: currentSource?.stashboxEndpoint,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateStudio({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: studioID,
|
||||||
|
stash_ids: stashIDs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newSearchResults = mapResults((r) => {
|
||||||
|
if (!r.studio) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
studio:
|
||||||
|
r.remote_site_id === studio.remote_site_id
|
||||||
|
? {
|
||||||
|
...r.studio,
|
||||||
|
stored_id: studioID,
|
||||||
|
}
|
||||||
|
: r.studio,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setSearchResults(newSearchResults);
|
||||||
|
|
||||||
|
Toast.success({
|
||||||
|
content: <span>Added stash-id to studio</span>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaggerStateContext.Provider
|
||||||
|
value={{
|
||||||
|
config: config ?? initialConfig,
|
||||||
|
setConfig,
|
||||||
|
loading: loading || loadingMulti,
|
||||||
|
loadingMulti,
|
||||||
|
multiError,
|
||||||
|
sources,
|
||||||
|
currentSource,
|
||||||
|
searchResults,
|
||||||
|
setCurrentSource: (src) => {
|
||||||
|
setCurrentSource(src);
|
||||||
|
},
|
||||||
|
doSceneQuery,
|
||||||
|
doSceneFragmentScrape,
|
||||||
|
doMultiSceneFragmentScrape,
|
||||||
|
stopMultiScrape,
|
||||||
|
createNewTag,
|
||||||
|
createNewPerformer,
|
||||||
|
linkPerformer,
|
||||||
|
createNewStudio,
|
||||||
|
linkStudio,
|
||||||
|
resolveScene,
|
||||||
|
saveScene,
|
||||||
|
submitFingerprints,
|
||||||
|
pendingFingerprints: getPendingFingerprints(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TaggerStateContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,11 +18,7 @@ import { ConfigurationContext } from "src/hooks/Config";
|
|||||||
import StashSearchResult from "./StashSearchResult";
|
import StashSearchResult from "./StashSearchResult";
|
||||||
import PerformerConfig from "./Config";
|
import PerformerConfig from "./Config";
|
||||||
import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants";
|
import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants";
|
||||||
import {
|
import { IStashBoxPerformer, selectPerformers } from "../utils";
|
||||||
IStashBoxPerformer,
|
|
||||||
selectPerformers,
|
|
||||||
filterPerformer,
|
|
||||||
} from "../utils";
|
|
||||||
import PerformerModal from "../PerformerModal";
|
import PerformerModal from "../PerformerModal";
|
||||||
import { useUpdatePerformer } from "../queries";
|
import { useUpdatePerformer } from "../queries";
|
||||||
|
|
||||||
@@ -171,22 +167,16 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
|
|||||||
|
|
||||||
const updatePerformer = useUpdatePerformer();
|
const updatePerformer = useUpdatePerformer();
|
||||||
|
|
||||||
const handlePerformerUpdate = async (
|
const handlePerformerUpdate = async (input: GQL.PerformerCreateInput) => {
|
||||||
imageIndex: number,
|
|
||||||
excludedFields: string[]
|
|
||||||
) => {
|
|
||||||
const performerData = modalPerformer;
|
const performerData = modalPerformer;
|
||||||
setModalPerformer(undefined);
|
setModalPerformer(undefined);
|
||||||
if (performerData?.id) {
|
if (performerData?.id) {
|
||||||
const filteredData = filterPerformer(performerData, excludedFields);
|
const updateData: GQL.PerformerUpdateInput = {
|
||||||
|
|
||||||
const res = await updatePerformer({
|
|
||||||
...filteredData,
|
|
||||||
image: excludedFields.includes("image")
|
|
||||||
? undefined
|
|
||||||
: performerData.images[imageIndex],
|
|
||||||
id: performerData.id,
|
id: performerData.id,
|
||||||
});
|
...input,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await updatePerformer(updateData);
|
||||||
if (!res.data?.performerUpdate)
|
if (!res.data?.performerUpdate)
|
||||||
setError({
|
setError({
|
||||||
...error,
|
...error,
|
||||||
@@ -200,7 +190,6 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setModalPerformer(undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPerformers = () =>
|
const renderPerformers = () =>
|
||||||
@@ -351,7 +340,7 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
|
|||||||
closeModal={() => setModalPerformer(undefined)}
|
closeModal={() => setModalPerformer(undefined)}
|
||||||
modalVisible={modalPerformer !== undefined}
|
modalVisible={modalPerformer !== undefined}
|
||||||
performer={modalPerformer}
|
performer={modalPerformer}
|
||||||
handlePerformerCreate={handlePerformerUpdate}
|
onSave={handlePerformerUpdate}
|
||||||
excludedPerformerFields={config.excludedPerformerFields}
|
excludedPerformerFields={config.excludedPerformerFields}
|
||||||
icon="tags"
|
icon="tags"
|
||||||
header="Update Performer"
|
header="Update Performer"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { Button } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
|
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { IStashBoxPerformer, filterPerformer } from "../utils";
|
import { IStashBoxPerformer } from "../utils";
|
||||||
import { useUpdatePerformer } from "../queries";
|
import { useUpdatePerformer } from "../queries";
|
||||||
import PerformerModal from "../PerformerModal";
|
import PerformerModal from "../PerformerModal";
|
||||||
|
|
||||||
@@ -34,21 +34,19 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||||||
|
|
||||||
const updatePerformer = useUpdatePerformer();
|
const updatePerformer = useUpdatePerformer();
|
||||||
|
|
||||||
const handleSave = async (image: number, excludedFields: string[]) => {
|
const handleSave = async (input: GQL.PerformerCreateInput) => {
|
||||||
if (modalPerformer) {
|
const performerData = modalPerformer;
|
||||||
const performerData = filterPerformer(modalPerformer, excludedFields);
|
if (performerData?.id) {
|
||||||
setError({});
|
setError({});
|
||||||
setSaveState("Saving performer");
|
setSaveState("Saving performer");
|
||||||
setModalPerformer(undefined);
|
setModalPerformer(undefined);
|
||||||
|
|
||||||
const res = await updatePerformer({
|
const updateData: GQL.PerformerUpdateInput = {
|
||||||
...performerData,
|
id: performerData.id,
|
||||||
image: excludedFields.includes("image")
|
...input,
|
||||||
? undefined
|
};
|
||||||
: modalPerformer.images[image],
|
|
||||||
stash_ids: [{ stash_id: modalPerformer.stash_id, endpoint }],
|
const res = await updatePerformer(updateData);
|
||||||
id: performer.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res?.data?.performerUpdate)
|
if (!res?.data?.performerUpdate)
|
||||||
setError({
|
setError({
|
||||||
@@ -83,7 +81,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
|||||||
closeModal={() => setModalPerformer(undefined)}
|
closeModal={() => setModalPerformer(undefined)}
|
||||||
modalVisible={modalPerformer !== undefined}
|
modalVisible={modalPerformer !== undefined}
|
||||||
performer={modalPerformer}
|
performer={modalPerformer}
|
||||||
handlePerformerCreate={handleSave}
|
onSave={handleSave}
|
||||||
icon="tags"
|
icon="tags"
|
||||||
header="Update Performer"
|
header="Update Performer"
|
||||||
excludedPerformerFields={excludedPerformerFields}
|
excludedPerformerFields={excludedPerformerFields}
|
||||||
|
|||||||
130
ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx
Normal file
130
ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useState, useContext } from "react";
|
||||||
|
import * as GQL from "src/core/generated-graphql";
|
||||||
|
import PerformerModal from "./PerformerModal";
|
||||||
|
import StudioModal from "./StudioModal";
|
||||||
|
import { TaggerStateContext } from "./context";
|
||||||
|
|
||||||
|
type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void;
|
||||||
|
type StudioModalCallback = (toCreate?: GQL.StudioCreateInput) => void;
|
||||||
|
|
||||||
|
export interface ISceneTaggerModalsContextState {
|
||||||
|
createPerformerModal: (
|
||||||
|
performer: GQL.ScrapedPerformerDataFragment,
|
||||||
|
callback: (toCreate?: GQL.PerformerCreateInput) => void
|
||||||
|
) => void;
|
||||||
|
createStudioModal: (
|
||||||
|
studio: GQL.ScrapedSceneStudioDataFragment,
|
||||||
|
callback: (toCreate?: GQL.StudioCreateInput) => void
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SceneTaggerModalsState = React.createContext<ISceneTaggerModalsContextState>(
|
||||||
|
{
|
||||||
|
createPerformerModal: () => {},
|
||||||
|
createStudioModal: () => {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SceneTaggerModals: React.FC = ({ children }) => {
|
||||||
|
const { currentSource } = useContext(TaggerStateContext);
|
||||||
|
|
||||||
|
const [performerToCreate, setPerformerToCreate] = useState<
|
||||||
|
GQL.ScrapedPerformerDataFragment | undefined
|
||||||
|
>();
|
||||||
|
const [performerCallback, setPerformerCallback] = useState<
|
||||||
|
PerformerModalCallback | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
const [studioToCreate, setStudioToCreate] = useState<
|
||||||
|
GQL.ScrapedSceneStudioDataFragment | undefined
|
||||||
|
>();
|
||||||
|
const [studioCallback, setStudioCallback] = useState<
|
||||||
|
StudioModalCallback | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
|
function handlePerformerSave(toCreate: GQL.PerformerCreateInput) {
|
||||||
|
if (performerCallback) {
|
||||||
|
performerCallback(toCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPerformerToCreate(undefined);
|
||||||
|
setPerformerCallback(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePerformerCancel() {
|
||||||
|
if (performerCallback) {
|
||||||
|
performerCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPerformerToCreate(undefined);
|
||||||
|
setPerformerCallback(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPerformerModal(
|
||||||
|
performer: GQL.ScrapedPerformerDataFragment,
|
||||||
|
callback: PerformerModalCallback
|
||||||
|
) {
|
||||||
|
setPerformerToCreate(performer);
|
||||||
|
// can't set the function directly - needs to be via a wrapping function
|
||||||
|
setPerformerCallback(() => callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStudioSave(toCreate: GQL.StudioCreateInput) {
|
||||||
|
if (studioCallback) {
|
||||||
|
studioCallback(toCreate);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStudioToCreate(undefined);
|
||||||
|
setStudioCallback(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStudioCancel() {
|
||||||
|
if (studioCallback) {
|
||||||
|
studioCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStudioToCreate(undefined);
|
||||||
|
setStudioCallback(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStudioModal(
|
||||||
|
studio: GQL.ScrapedSceneStudioDataFragment,
|
||||||
|
callback: StudioModalCallback
|
||||||
|
) {
|
||||||
|
setStudioToCreate(studio);
|
||||||
|
// can't set the function directly - needs to be via a wrapping function
|
||||||
|
setStudioCallback(() => callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = currentSource?.stashboxEndpoint;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SceneTaggerModalsState.Provider
|
||||||
|
value={{ createPerformerModal, createStudioModal }}
|
||||||
|
>
|
||||||
|
{performerToCreate && (
|
||||||
|
<PerformerModal
|
||||||
|
closeModal={handlePerformerCancel}
|
||||||
|
modalVisible
|
||||||
|
performer={performerToCreate}
|
||||||
|
onSave={handlePerformerSave}
|
||||||
|
icon="tags"
|
||||||
|
header="Create Performer"
|
||||||
|
endpoint={endpoint}
|
||||||
|
create
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{studioToCreate && (
|
||||||
|
<StudioModal
|
||||||
|
closeModal={handleStudioCancel}
|
||||||
|
modalVisible
|
||||||
|
studio={studioToCreate}
|
||||||
|
handleStudioCreate={handleStudioSave}
|
||||||
|
icon="tags"
|
||||||
|
header="Create Studio"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</SceneTaggerModalsState.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
.tagger-container {
|
.tagger-container {
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
|
|
||||||
|
.tagger-container-header {
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scene-card-preview {
|
.scene-card-preview {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -28,6 +33,12 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
||||||
.scene-details {
|
.scene-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.original-scene-details {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -65,11 +76,10 @@
|
|||||||
max-width: 14rem;
|
max-width: 14rem;
|
||||||
min-width: 168px;
|
min-width: 168px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-metadata {
|
.scene-metadata {
|
||||||
margin-right: 1rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-existing {
|
.select-existing {
|
||||||
@@ -230,11 +240,15 @@ li:not(.active) {
|
|||||||
|
|
||||||
.optional-field {
|
.optional-field {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
li.active .optional-field.excluded,
|
li.active .optional-field.missing .optional-field-content {
|
||||||
|
color: #bfccd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.active .optional-field.excluded .optional-field-content,
|
||||||
li.active .optional-field.excluded .scene-link {
|
li.active .optional-field.excluded .scene-link {
|
||||||
color: #bfccd6;
|
color: #bfccd6;
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
@@ -244,11 +258,12 @@ li.active .optional-field.excluded .scene-link {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
li.active .scene-image-container {
|
// li.active .scene-image-container {
|
||||||
margin-left: 1rem;
|
// margin-left: 1rem;
|
||||||
}
|
// }
|
||||||
|
|
||||||
.scene-details {
|
.scene-details,
|
||||||
|
.original-scene-details {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
> .row {
|
> .row {
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import * as GQL from "src/core/generated-graphql";
|
|
||||||
import { blobToBase64 } from "base64-blob";
|
|
||||||
import {
|
|
||||||
useCreatePerformer,
|
|
||||||
useCreateStudio,
|
|
||||||
useUpdatePerformerStashID,
|
|
||||||
useUpdateStudioStashID,
|
|
||||||
} from "./queries";
|
|
||||||
import { IPerformerOperations } from "./PerformerResult";
|
|
||||||
import { StudioOperation } from "./StudioResult";
|
|
||||||
import { IStashBoxScene } from "./utils";
|
|
||||||
|
|
||||||
export interface ITagSceneOptions {
|
|
||||||
setCoverImage?: boolean;
|
|
||||||
setTags?: boolean;
|
|
||||||
tagOperation: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTagScene(
|
|
||||||
options: ITagSceneOptions,
|
|
||||||
setSaveState: (state: string) => void,
|
|
||||||
setError: (err: { message?: string; details?: string }) => void
|
|
||||||
) {
|
|
||||||
const createStudio = useCreateStudio();
|
|
||||||
const createPerformer = useCreatePerformer();
|
|
||||||
const updatePerformerStashID = useUpdatePerformerStashID();
|
|
||||||
const updateStudioStashID = useUpdateStudioStashID();
|
|
||||||
const [updateScene] = GQL.useSceneUpdateMutation({
|
|
||||||
onError: (e) => {
|
|
||||||
const message =
|
|
||||||
e.message === "invalid JPEG format: short Huffman data"
|
|
||||||
? "Failed to save scene due to corrupted cover image"
|
|
||||||
: "Failed to save scene";
|
|
||||||
setError({
|
|
||||||
message,
|
|
||||||
details: e.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSave = async (
|
|
||||||
stashScene: GQL.SlimSceneDataFragment,
|
|
||||||
scene: IStashBoxScene,
|
|
||||||
studio: StudioOperation | undefined,
|
|
||||||
performers: IPerformerOperations,
|
|
||||||
tagIDs: string[],
|
|
||||||
excludedFields: string[],
|
|
||||||
endpoint: string
|
|
||||||
) => {
|
|
||||||
function resolveField<T>(field: string, stashField: T, remoteField: T) {
|
|
||||||
if (excludedFields.includes(field)) {
|
|
||||||
return stashField;
|
|
||||||
}
|
|
||||||
|
|
||||||
return remoteField;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError({});
|
|
||||||
let performerIDs = [];
|
|
||||||
let studioID = null;
|
|
||||||
|
|
||||||
if (studio) {
|
|
||||||
if (studio.type === "create") {
|
|
||||||
setSaveState("Creating studio");
|
|
||||||
const newStudio = {
|
|
||||||
name: studio.data.name,
|
|
||||||
stash_ids: [
|
|
||||||
{
|
|
||||||
endpoint,
|
|
||||||
stash_id: scene.studio.stash_id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
url: studio.data.url,
|
|
||||||
};
|
|
||||||
const studioCreateResult = await createStudio(
|
|
||||||
newStudio,
|
|
||||||
scene.studio.stash_id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!studioCreateResult?.data?.studioCreate) {
|
|
||||||
setError({
|
|
||||||
message: `Failed to save studio "${newStudio.name}"`,
|
|
||||||
details: studioCreateResult?.errors?.[0].message,
|
|
||||||
});
|
|
||||||
return setSaveState("");
|
|
||||||
}
|
|
||||||
studioID = studioCreateResult.data.studioCreate.id;
|
|
||||||
} else if (studio.type === "update") {
|
|
||||||
setSaveState("Saving studio stashID");
|
|
||||||
const res = await updateStudioStashID(studio.data, [
|
|
||||||
...studio.data.stash_ids,
|
|
||||||
{ stash_id: scene.studio.stash_id, endpoint },
|
|
||||||
]);
|
|
||||||
if (!res?.data?.studioUpdate) {
|
|
||||||
setError({
|
|
||||||
message: `Failed to save stashID to studio "${studio.data.name}"`,
|
|
||||||
details: res?.errors?.[0].message,
|
|
||||||
});
|
|
||||||
return setSaveState("");
|
|
||||||
}
|
|
||||||
studioID = res.data.studioUpdate.id;
|
|
||||||
} else if (studio.type === "existing") {
|
|
||||||
studioID = studio.data.id;
|
|
||||||
} else if (studio.type === "skip") {
|
|
||||||
studioID = stashScene.studio?.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaveState("Saving performers");
|
|
||||||
let failed = false;
|
|
||||||
performerIDs = await Promise.all(
|
|
||||||
Object.keys(performers).map(async (stashID) => {
|
|
||||||
const performer = performers[stashID];
|
|
||||||
if (performer.type === "skip") return "Skip";
|
|
||||||
|
|
||||||
let performerID = performer.data.id;
|
|
||||||
|
|
||||||
if (performer.type === "create") {
|
|
||||||
const imgurl = performer.data.images[0];
|
|
||||||
let imgData = null;
|
|
||||||
if (imgurl) {
|
|
||||||
const img = await fetch(imgurl, {
|
|
||||||
mode: "cors",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (img.status === 200) {
|
|
||||||
const blob = await img.blob();
|
|
||||||
imgData = await blobToBase64(blob);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const performerInput = {
|
|
||||||
name: performer.data.name,
|
|
||||||
gender: performer.data.gender,
|
|
||||||
country: performer.data.country,
|
|
||||||
height: performer.data.height,
|
|
||||||
ethnicity: performer.data.ethnicity,
|
|
||||||
birthdate: performer.data.birthdate,
|
|
||||||
eye_color: performer.data.eye_color,
|
|
||||||
fake_tits: performer.data.fake_tits,
|
|
||||||
measurements: performer.data.measurements,
|
|
||||||
career_length: performer.data.career_length,
|
|
||||||
tattoos: performer.data.tattoos,
|
|
||||||
piercings: performer.data.piercings,
|
|
||||||
twitter: performer.data.twitter,
|
|
||||||
instagram: performer.data.instagram,
|
|
||||||
image: imgData,
|
|
||||||
stash_ids: [
|
|
||||||
{
|
|
||||||
endpoint,
|
|
||||||
stash_id: stashID,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: performer.data.details,
|
|
||||||
death_date: performer.data.death_date,
|
|
||||||
hair_color: performer.data.hair_color,
|
|
||||||
weight: Number(performer.data.weight),
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await createPerformer(performerInput, stashID);
|
|
||||||
if (!res?.data?.performerCreate) {
|
|
||||||
setError({
|
|
||||||
message: `Failed to save performer "${performerInput.name}"`,
|
|
||||||
details: res?.errors?.[0].message,
|
|
||||||
});
|
|
||||||
failed = true;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
performerID = res.data?.performerCreate.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (performer.type === "update") {
|
|
||||||
const stashIDs = performer.data.stash_ids;
|
|
||||||
await updatePerformerStashID(performer.data.id, [
|
|
||||||
...stashIDs,
|
|
||||||
{ stash_id: stashID, endpoint },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return performerID;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
return setSaveState("");
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaveState("Updating scene");
|
|
||||||
const imgurl = scene.images[0];
|
|
||||||
let imgData;
|
|
||||||
if (imgurl && options.setCoverImage) {
|
|
||||||
const img = await fetch(imgurl, {
|
|
||||||
mode: "cors",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (img.status === 200) {
|
|
||||||
const blob = await img.blob();
|
|
||||||
// Sanity check on image size since bad images will fail
|
|
||||||
if (blob.size > 10000) imgData = await blobToBase64(blob);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const performer_ids = performerIDs.filter(
|
|
||||||
(id) => id !== "Skip"
|
|
||||||
) as string[];
|
|
||||||
|
|
||||||
const sceneUpdateResult = await updateScene({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
id: stashScene.id ?? "",
|
|
||||||
title: resolveField("title", stashScene.title, scene.title),
|
|
||||||
details: resolveField("details", stashScene.details, scene.details),
|
|
||||||
date: resolveField("date", stashScene.date, scene.date),
|
|
||||||
performer_ids:
|
|
||||||
performer_ids.length === 0
|
|
||||||
? stashScene.performers.map((p) => p.id)
|
|
||||||
: performer_ids,
|
|
||||||
studio_id: studioID,
|
|
||||||
cover_image: resolveField("cover_image", undefined, imgData),
|
|
||||||
url: resolveField("url", stashScene.url, scene.url),
|
|
||||||
tag_ids: tagIDs,
|
|
||||||
stash_ids: [
|
|
||||||
...(stashScene?.stash_ids ?? []),
|
|
||||||
{
|
|
||||||
endpoint,
|
|
||||||
stash_id: scene.stash_id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setSaveState("");
|
|
||||||
return sceneUpdateResult?.data?.sceneUpdate;
|
|
||||||
};
|
|
||||||
|
|
||||||
return handleSave;
|
|
||||||
}
|
|
||||||
@@ -102,7 +102,7 @@ export function prepareQueryString(
|
|||||||
s = filename;
|
s = filename;
|
||||||
} else if (mode === "path") {
|
} else if (mode === "path") {
|
||||||
s = [...paths, filename].join(" ");
|
s = [...paths, filename].join(" ");
|
||||||
} else {
|
} else if (mode === "dir" && paths.length) {
|
||||||
s = paths[paths.length - 1];
|
s = paths[paths.length - 1];
|
||||||
}
|
}
|
||||||
blacklist.forEach((b) => {
|
blacklist.forEach((b) => {
|
||||||
|
|||||||
@@ -214,6 +214,14 @@ export const useSceneStreams = (id: string) =>
|
|||||||
export const useFindImage = (id: string) =>
|
export const useFindImage = (id: string) =>
|
||||||
GQL.useFindImageQuery({ variables: { id } });
|
GQL.useFindImageQuery({ variables: { id } });
|
||||||
|
|
||||||
|
export const queryFindPerformer = (id: string) =>
|
||||||
|
client.query<GQL.FindPerformerQuery>({
|
||||||
|
query: GQL.FindPerformerDocument,
|
||||||
|
variables: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const useFindPerformer = (id: string) => {
|
export const useFindPerformer = (id: string) => {
|
||||||
const skip = id === "new";
|
const skip = id === "new";
|
||||||
return GQL.useFindPerformerQuery({ variables: { id }, skip });
|
return GQL.useFindPerformerQuery({ variables: { id }, skip });
|
||||||
@@ -222,6 +230,13 @@ export const useFindStudio = (id: string) => {
|
|||||||
const skip = id === "new";
|
const skip = id === "new";
|
||||||
return GQL.useFindStudioQuery({ variables: { id }, skip });
|
return GQL.useFindStudioQuery({ variables: { id }, skip });
|
||||||
};
|
};
|
||||||
|
export const queryFindStudio = (id: string) =>
|
||||||
|
client.query<GQL.FindStudioQuery>({
|
||||||
|
query: GQL.FindStudioDocument,
|
||||||
|
variables: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
export const useFindMovie = (id: string) => {
|
export const useFindMovie = (id: string) => {
|
||||||
const skip = id === "new";
|
const skip = id === "new";
|
||||||
return GQL.useFindMovieQuery({ variables: { id }, skip });
|
return GQL.useFindMovieQuery({ variables: { id }, skip });
|
||||||
|
|||||||
@@ -49,14 +49,30 @@ export const ToastProvider: React.FC = ({ children }) => {
|
|||||||
function createHookObject(toastFunc: (toast: IToast) => void) {
|
function createHookObject(toastFunc: (toast: IToast) => void) {
|
||||||
return {
|
return {
|
||||||
success: toastFunc,
|
success: toastFunc,
|
||||||
error: (error: Error) => {
|
error: (error: unknown) => {
|
||||||
// eslint-disable-next-line no-console
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
console.error(error.message);
|
let message: string;
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message ?? error.toString();
|
||||||
|
} else if ((error as any).toString) {
|
||||||
|
message = (error as any).toString();
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
toastFunc({
|
||||||
|
variant: "danger",
|
||||||
|
header: "Error",
|
||||||
|
content: "Unknown error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(message);
|
||||||
toastFunc({
|
toastFunc({
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
header: "Error",
|
header: "Error",
|
||||||
content: error.message ?? error.toString(),
|
content: message,
|
||||||
});
|
});
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any, no-console */
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"scan": "Scan",
|
"scan": "Scan",
|
||||||
"scrape_with": "Scrape with…",
|
"scrape_with": "Scrape with…",
|
||||||
"scrape_query": "Scrape query",
|
"scrape_query": "Scrape query",
|
||||||
|
"scrape_scene_fragment": "Scrape by fragment",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"select_all": "Select All",
|
"select_all": "Select All",
|
||||||
"select_none": "Select None",
|
"select_none": "Select None",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"set_image": "Set image…",
|
"set_image": "Set image…",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"skip": "Skip",
|
"skip": "Skip",
|
||||||
|
"stop": "Stop",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"clean_confirm_message": "Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.",
|
"clean_confirm_message": "Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.",
|
||||||
"dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.",
|
"dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.",
|
||||||
@@ -119,6 +121,7 @@
|
|||||||
"set_cover_label": "Set scene cover image",
|
"set_cover_label": "Set scene cover image",
|
||||||
"set_tag_desc": "Attach tags to scene, either by overwriting or merging with existing tags on scene.",
|
"set_tag_desc": "Attach tags to scene, either by overwriting or merging with existing tags on scene.",
|
||||||
"set_tag_label": "Set tags",
|
"set_tag_label": "Set tags",
|
||||||
|
"source": "Source",
|
||||||
"show_male_desc": "Toggle whether male performers will be available to tag.",
|
"show_male_desc": "Toggle whether male performers will be available to tag.",
|
||||||
"show_male_label": "Show male performers"
|
"show_male_label": "Show male performers"
|
||||||
},
|
},
|
||||||
@@ -131,11 +134,13 @@
|
|||||||
"match_failed_already_tagged": "Scene already tagged",
|
"match_failed_already_tagged": "Scene already tagged",
|
||||||
"match_failed_no_result": "No results found",
|
"match_failed_no_result": "No results found",
|
||||||
"match_success": "Scene successfully tagged",
|
"match_success": "Scene successfully tagged",
|
||||||
|
"unnamed": "Unnamed",
|
||||||
"duration_off": "Duration off by at least {number}s",
|
"duration_off": "Duration off by at least {number}s",
|
||||||
"duration_unknown": "Duration unknown"
|
"duration_unknown": "Duration unknown"
|
||||||
},
|
},
|
||||||
"verb_match_fp": "Match Fingerprints",
|
"verb_match_fp": "Match Fingerprints",
|
||||||
"verb_matched": "Matched",
|
"verb_matched": "Matched",
|
||||||
|
"verb_scrape_all": "Scrape All",
|
||||||
"verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}",
|
"verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}",
|
||||||
"verb_toggle_config": "{toggle} {configuration}",
|
"verb_toggle_config": "{toggle} {configuration}",
|
||||||
"verb_toggle_unmatched": "{toggle} unmatched scenes"
|
"verb_toggle_unmatched": "{toggle} unmatched scenes"
|
||||||
|
|||||||
Reference in New Issue
Block a user