mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +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
|
||||
* 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 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 { ExportDialog } from "../Shared/ExportDialog";
|
||||
import { SceneCardsGrid } from "./SceneCardsGrid";
|
||||
import { TaggerContext } from "../Tagger/context";
|
||||
|
||||
interface ISceneList {
|
||||
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;
|
||||
inline?: boolean;
|
||||
small?: boolean;
|
||||
card?: boolean;
|
||||
}
|
||||
|
||||
const CLASSNAME = "LoadingIndicator";
|
||||
@@ -15,8 +16,9 @@ const LoadingIndicator: React.FC<ILoadingProps> = ({
|
||||
message,
|
||||
inline = 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}>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
&:not(.card-based) {
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
&-message {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Dispatch, useRef } from "react";
|
||||
import React, { useRef, useContext } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -9,29 +9,18 @@ import {
|
||||
} from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import { useConfiguration } from "src/core/StashService";
|
||||
|
||||
import { ITaggerConfig, ParseMode, TagOperation } from "./constants";
|
||||
import { ParseMode, TagOperation } from "./constants";
|
||||
import { TaggerStateContext } from "./context";
|
||||
|
||||
interface IConfigProps {
|
||||
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 stashConfig = useConfiguration();
|
||||
const blacklistRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleInstanceSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedEndpoint = e.currentTarget.value;
|
||||
setConfig({
|
||||
...config,
|
||||
selectedEndpoint,
|
||||
});
|
||||
};
|
||||
|
||||
const removeBlacklist = (index: number) => {
|
||||
setConfig({
|
||||
...config,
|
||||
@@ -55,8 +44,6 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
||||
blacklistRef.current.value = "";
|
||||
};
|
||||
|
||||
const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? [];
|
||||
|
||||
return (
|
||||
<Collapse in={show}>
|
||||
<Card>
|
||||
@@ -221,29 +208,6 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -27,6 +27,7 @@ export const IncludeExcludeButton: React.FC<IIncludeExcludeButton> = ({
|
||||
|
||||
interface IOptionalField {
|
||||
exclude: boolean;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
setExclude: (v: boolean) => void;
|
||||
}
|
||||
@@ -35,9 +36,13 @@ export const OptionalField: React.FC<IOptionalField> = ({
|
||||
exclude,
|
||||
setExclude,
|
||||
children,
|
||||
}) => (
|
||||
title,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`optional-field ${!exclude ? "included" : "excluded"}`}>
|
||||
<IncludeExcludeButton exclude={exclude} setExclude={setExclude} />
|
||||
{children}
|
||||
{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 { Button } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import cx from "classnames";
|
||||
import { IconName } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
@@ -11,26 +11,24 @@ import {
|
||||
TruncatedText,
|
||||
} from "src/components/Shared";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { TextUtils } from "src/utils";
|
||||
import { genderToString } from "src/utils/gender";
|
||||
import { IStashBoxPerformer } from "./utils";
|
||||
import { genderToString, stringToGender } from "src/utils/gender";
|
||||
|
||||
interface IPerformerModalProps {
|
||||
performer: IStashBoxPerformer;
|
||||
performer: GQL.ScrapedScenePerformerDataFragment;
|
||||
modalVisible: boolean;
|
||||
closeModal: () => void;
|
||||
handlePerformerCreate: (imageIndex: number, excludedFields: string[]) => void;
|
||||
onSave: (input: GQL.PerformerCreateInput) => void;
|
||||
excludedPerformerFields?: string[];
|
||||
header: string;
|
||||
icon: IconName;
|
||||
create?: boolean;
|
||||
endpoint: string;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||
modalVisible,
|
||||
performer,
|
||||
handlePerformerCreate,
|
||||
onSave,
|
||||
closeModal,
|
||||
excludedPerformerFields = [],
|
||||
header,
|
||||
@@ -39,6 +37,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||
endpoint,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [imageIndex, setImageIndex] = useState(0);
|
||||
const [imageState, setImageState] = useState<
|
||||
"loading" | "error" | "loaded" | "empty"
|
||||
@@ -51,7 +50,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||
)
|
||||
);
|
||||
|
||||
const { images } = performer;
|
||||
const images = performer.images ?? [];
|
||||
|
||||
const changeImage = (index: number) => {
|
||||
setImageIndex(index);
|
||||
@@ -94,7 +93,9 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||
<Icon icon={excluded[name] ? "times" : "check"} />
|
||||
</Button>
|
||||
)}
|
||||
<strong>{TextUtils.capitalize(name)}:</strong>
|
||||
<strong>
|
||||
<FormattedMessage id={name} />:
|
||||
</strong>
|
||||
</div>
|
||||
{truncate ? (
|
||||
<TruncatedText className="col-7" text={text} />
|
||||
@@ -104,19 +105,77 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const base = endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
const link = base ? `${base}performers/${performer.stash_id}` : undefined;
|
||||
const base = endpoint?.match(/https?:\/\/.*?\//)?.[0];
|
||||
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 (
|
||||
<Modal
|
||||
show={modalVisible}
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.save" }),
|
||||
onClick: () =>
|
||||
handlePerformerCreate(
|
||||
imageIndex,
|
||||
create ? [] : Object.keys(excluded).filter((key) => excluded[key])
|
||||
),
|
||||
onClick: onSaveClicked,
|
||||
}}
|
||||
cancel={{ onClick: () => closeModal(), variant: "secondary" }}
|
||||
onHide={() => closeModal()}
|
||||
@@ -127,7 +186,10 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||
<div className="row">
|
||||
<div className="col-7">
|
||||
{renderField("name", performer.name)}
|
||||
{renderField("gender", genderToString(performer.gender))}
|
||||
{renderField(
|
||||
"gender",
|
||||
performer.gender ? genderToString(performer.gender) : ""
|
||||
)}
|
||||
{renderField("birthdate", performer.birthdate)}
|
||||
{renderField("death_date", performer.death_date)}
|
||||
{renderField("ethnicity", performer.ethnicity)}
|
||||
@@ -142,6 +204,11 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
|
||||
{renderField("career_length", performer.career_length)}
|
||||
{renderField("tattoos", performer.tattoos, 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 && (
|
||||
<h6 className="mt-2">
|
||||
<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 { FormattedMessage } from "react-intl";
|
||||
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 { ValidTypes } from "src/components/Shared/Select";
|
||||
import { IStashBoxPerformer, filterPerformer } from "./utils";
|
||||
|
||||
import PerformerModal from "./PerformerModal";
|
||||
import { OptionalField } from "./IncludeButton";
|
||||
|
||||
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;
|
||||
}
|
||||
import { OperationButton } from "../Shared/OperationButton";
|
||||
|
||||
interface IPerformerResultProps {
|
||||
performer: IStashBoxPerformer;
|
||||
setPerformer: (data: PerformerOperation) => void;
|
||||
endpoint: string;
|
||||
performer: GQL.ScrapedPerformer;
|
||||
selectedID: string | undefined;
|
||||
setSelectedID: (id: string | undefined) => void;
|
||||
onCreate: () => void;
|
||||
onLink?: () => Promise<void>;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
const PerformerResult: React.FC<IPerformerResultProps> = ({
|
||||
performer,
|
||||
setPerformer,
|
||||
selectedID,
|
||||
setSelectedID,
|
||||
onCreate,
|
||||
onLink,
|
||||
endpoint,
|
||||
}) => {
|
||||
const [selectedPerformer, setSelectedPerformer] = useState<string | null>();
|
||||
const [selectedSource, setSelectedSource] = useState<
|
||||
"create" | "existing" | "skip" | undefined
|
||||
>();
|
||||
const [modalVisible, showModal] = useState(false);
|
||||
const { data: performerData } = GQL.useFindPerformerQuery({
|
||||
variables: { id: performer.id ?? "" },
|
||||
skip: !performer.id,
|
||||
const {
|
||||
data: performerData,
|
||||
loading: stashLoading,
|
||||
} = GQL.useFindPerformerQuery({
|
||||
variables: { id: performer.stored_id ?? "" },
|
||||
skip: !performer.stored_id,
|
||||
});
|
||||
const { data: stashData, loading: stashLoading } = GQL.useFindPerformersQuery(
|
||||
{
|
||||
variables: {
|
||||
performer_filter: {
|
||||
stash_id: {
|
||||
value: performer.stash_id,
|
||||
modifier: GQL.CriterionModifier.Equals,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (stashData?.findPerformers.performers.length)
|
||||
setPerformer({
|
||||
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 matchedPerformer = performerData?.findPerformer;
|
||||
const matchedStashID = matchedPerformer?.stash_ids.some(
|
||||
(stashID) => stashID.endpoint === endpoint && stashID.stash_id
|
||||
);
|
||||
|
||||
const handlePerformerSelect = (performers: ValidTypes[]) => {
|
||||
if (performers.length) {
|
||||
setSelectedSource("existing");
|
||||
setSelectedPerformer(performers[0].id);
|
||||
setPerformer({
|
||||
type: "update",
|
||||
data: performers[0] as GQL.SlimPerformerDataFragment,
|
||||
});
|
||||
setSelectedID(performers[0].id);
|
||||
} else {
|
||||
setSelectedSource(undefined);
|
||||
setSelectedPerformer(null);
|
||||
setSelectedID(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
setSelectedSource("skip");
|
||||
setPerformer({
|
||||
type: "skip",
|
||||
});
|
||||
setSelectedID(undefined);
|
||||
};
|
||||
|
||||
if (stashLoading) return <div>Loading performer</div>;
|
||||
|
||||
if (stashData?.findPerformers.performers?.[0]?.id) {
|
||||
if (matchedPerformer && matchedStashID) {
|
||||
return (
|
||||
<div className="row no-gutters my-2">
|
||||
<div className="entity-name">
|
||||
@@ -123,45 +63,48 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
|
||||
</div>
|
||||
<span className="ml-auto">
|
||||
<OptionalField
|
||||
exclude={selectedSource === "skip"}
|
||||
exclude={selectedID === undefined}
|
||||
setExclude={(v) =>
|
||||
v ? handlePerformerSkip() : setSelectedSource("existing")
|
||||
v ? handlePerformerSkip() : setSelectedID(matchedPerformer.id)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className="mr-2">
|
||||
<FormattedMessage id="component_tagger.verb_matched" />:
|
||||
</span>
|
||||
<b className="col-3 text-right">
|
||||
{stashData.findPerformers.performers[0].name}
|
||||
</b>
|
||||
<b className="col-3 text-right">{matchedPerformer.name}</b>
|
||||
</div>
|
||||
</OptionalField>
|
||||
</span>
|
||||
</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 (
|
||||
<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">
|
||||
<FormattedMessage id="countables.performers" values={{ count: 1 }} />:
|
||||
<b className="ml-2">{performer.name}</b>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant={selectedSource === "create" ? "primary" : "secondary"}
|
||||
onClick={() => showModal(true)}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => onCreate()}>
|
||||
<FormattedMessage id="actions.create" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -171,13 +114,14 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
|
||||
<FormattedMessage id="actions.skip" />
|
||||
</Button>
|
||||
<PerformerSelect
|
||||
ids={selectedPerformer ? [selectedPerformer] : []}
|
||||
ids={selectedID ? [selectedID] : []}
|
||||
onSelect={handlePerformerSelect}
|
||||
className={cx("performer-select", {
|
||||
"performer-select-active": selectedSource === "existing",
|
||||
})}
|
||||
isClearable={false}
|
||||
/>
|
||||
{maybeRenderLinkButton()}
|
||||
</ButtonGroup>
|
||||
</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 { Badge, Button, Col, Form, Row } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
@@ -13,22 +13,30 @@ import {
|
||||
} from "src/components/Shared";
|
||||
import { FormUtils } from "src/utils";
|
||||
import { uniq } from "lodash";
|
||||
import PerformerResult, { PerformerOperation } from "./PerformerResult";
|
||||
import StudioResult, { StudioOperation } from "./StudioResult";
|
||||
import { IStashBoxScene } from "./utils";
|
||||
import { useTagScene } from "./taggerService";
|
||||
import { TagOperation } from "./constants";
|
||||
import { blobToBase64 } from "base64-blob";
|
||||
import { stringToGender } from "src/utils/gender";
|
||||
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 = (
|
||||
scene: IStashBoxScene,
|
||||
scene: IScrapedScene,
|
||||
stashDuration: number | undefined | null
|
||||
) => {
|
||||
if (!stashDuration) return "";
|
||||
|
||||
const durations = scene.fingerprints
|
||||
.map((f) => f.duration)
|
||||
.map((d) => Math.abs(d - stashDuration));
|
||||
const durations =
|
||||
scene.fingerprints
|
||||
?.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;
|
||||
|
||||
let match;
|
||||
@@ -39,7 +47,7 @@ const getDurationStatus = (
|
||||
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" />;
|
||||
|
||||
if (match)
|
||||
@@ -50,11 +58,8 @@ const getDurationStatus = (
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!scene.duration && durations.length === 0)
|
||||
return <FormattedMessage id="component_tagger.results.duration_unknown" />;
|
||||
|
||||
const minDiff = Math.min(
|
||||
Math.abs(scene.duration - stashDuration),
|
||||
Math.abs(sceneDuration - stashDuration),
|
||||
...durations
|
||||
);
|
||||
return (
|
||||
@@ -66,13 +71,13 @@ const getDurationStatus = (
|
||||
};
|
||||
|
||||
const getFingerprintStatus = (
|
||||
scene: IStashBoxScene,
|
||||
scene: IScrapedScene,
|
||||
stashScene: GQL.SlimSceneDataFragment
|
||||
) => {
|
||||
const checksumMatch = scene.fingerprints.some(
|
||||
const checksumMatch = scene.fingerprints?.some(
|
||||
(f) => f.hash === stashScene.checksum || f.hash === stashScene.oshash
|
||||
);
|
||||
const phashMatch = scene.fingerprints.some(
|
||||
const phashMatch = scene.fingerprints?.some(
|
||||
(f) => f.hash === stashScene.phash
|
||||
);
|
||||
if (checksumMatch || phashMatch)
|
||||
@@ -94,55 +99,60 @@ const getFingerprintStatus = (
|
||||
};
|
||||
|
||||
interface IStashSearchResultProps {
|
||||
scene: IStashBoxScene;
|
||||
scene: IScrapedScene;
|
||||
stashScene: GQL.SlimSceneDataFragment;
|
||||
index: number;
|
||||
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> = ({
|
||||
scene,
|
||||
stashScene,
|
||||
index,
|
||||
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 stashSceneTags = stashScene.tags.map((t) => t.id);
|
||||
if (!setTags) {
|
||||
if (!config.setTags) {
|
||||
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") {
|
||||
return newTags;
|
||||
}
|
||||
@@ -151,56 +161,65 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
}
|
||||
|
||||
throw new Error("unexpected tagOperation");
|
||||
}, [stashScene, tagOperation, scene, setTags]);
|
||||
}, [stashScene, scene, config]);
|
||||
|
||||
const [studio, setStudio] = useState<StudioOperation>();
|
||||
const [performers, dispatch] = useReducer(performerReducer, {});
|
||||
const [tagIDs, setTagIDs] = useState<string[]>(getInitialTags());
|
||||
const [saveState, setSaveState] = useState<string>("");
|
||||
const [error, setError] = useState<{ message?: string; details?: string }>(
|
||||
const getInitialPerformers = useCallback(() => {
|
||||
return performers.map((p) => p.stored_id ?? undefined);
|
||||
}, [performers]);
|
||||
|
||||
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(() => {
|
||||
setTagIDs(getInitialTags());
|
||||
}, [setTags, tagOperation, getInitialTags]);
|
||||
}, [getInitialTags]);
|
||||
|
||||
const tagScene = useTagScene(
|
||||
{
|
||||
tagOperation,
|
||||
setCoverImage,
|
||||
setTags,
|
||||
},
|
||||
setSaveState,
|
||||
setError
|
||||
);
|
||||
useEffect(() => {
|
||||
setPerformerIDs(getInitialPerformers());
|
||||
}, [getInitialPerformers]);
|
||||
|
||||
function getExcludedFields() {
|
||||
return Object.keys(excludedFields).filter((f) => excludedFields[f]);
|
||||
useEffect(() => {
|
||||
setStudioID(getInitialStudio());
|
||||
}, [getInitialStudio]);
|
||||
|
||||
useEffect(() => {
|
||||
async function doResolveScene() {
|
||||
try {
|
||||
setLoading(true);
|
||||
await resolveScene(stashScene.id, index, scene);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const updatedScene = await tagScene(
|
||||
stashScene,
|
||||
scene,
|
||||
studio,
|
||||
performers,
|
||||
tagIDs,
|
||||
getExcludedFields(),
|
||||
endpoint
|
||||
);
|
||||
|
||||
if (updatedScene) setScene(updatedScene);
|
||||
|
||||
queueFingerprintSubmission(stashScene.id, endpoint);
|
||||
if (isActive && !loading && !scene.resolved) {
|
||||
doResolveScene();
|
||||
}
|
||||
}, [isActive, loading, stashScene, index, resolveScene, scene]);
|
||||
|
||||
const setPerformer = (
|
||||
performerData: PerformerOperation,
|
||||
performerID: string
|
||||
) => dispatch({ id: performerID, data: performerData });
|
||||
const stashBoxURL = useMemo(() => {
|
||||
if (currentSource?.stashboxEndpoint && scene.remote_site_id) {
|
||||
const endpoint = currentSource.stashboxEndpoint;
|
||||
const endpointBase = endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
return `${endpointBase}scenes/${scene.remote_site_id}`;
|
||||
}
|
||||
}, [currentSource, scene]);
|
||||
|
||||
const setExcludedField = (name: string, value: boolean) =>
|
||||
setExcludedFields({
|
||||
@@ -208,33 +227,102 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
[name]: value,
|
||||
});
|
||||
|
||||
const classname = cx("row mx-0 mt-2 search-result", {
|
||||
"selected-result": isActive,
|
||||
});
|
||||
|
||||
const sceneTitle = scene.url ? (
|
||||
<a
|
||||
href={scene.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="scene-link"
|
||||
>
|
||||
<TruncatedText text={scene?.title} />
|
||||
</a>
|
||||
) : (
|
||||
<TruncatedText text={scene?.title} />
|
||||
async function handleSave() {
|
||||
const excludedFieldList = Object.keys(excludedFields).filter(
|
||||
(f) => excludedFields[f]
|
||||
);
|
||||
|
||||
const saveEnabled =
|
||||
Object.keys(performers ?? []).length ===
|
||||
scene.performers.filter((p) => p.gender !== "MALE" || showMales).length &&
|
||||
Object.keys(performers ?? []).every((id) => performers?.[id].type) &&
|
||||
saveState === "";
|
||||
function resolveField<T>(field: string, stashField: T, remoteField: T) {
|
||||
if (excludedFieldList.includes(field)) {
|
||||
return stashField;
|
||||
}
|
||||
|
||||
const endpointBase = endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
const stashBoxURL = endpointBase
|
||||
? `${endpointBase}scenes/${scene.stash_id}`
|
||||
: "";
|
||||
return remoteField;
|
||||
}
|
||||
|
||||
let imgData;
|
||||
if (!excludedFields.cover_image && config.setCoverImage) {
|
||||
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
|
||||
const fields = {
|
||||
@@ -243,47 +331,80 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
date: "date",
|
||||
url: "url",
|
||||
details: "details",
|
||||
studio: "studio",
|
||||
stash_ids: "stash_ids",
|
||||
};
|
||||
|
||||
const maybeRenderCoverImage = () => {
|
||||
if (scene.image) {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<li
|
||||
className={`${classname} ${isActive && "active"}`}
|
||||
key={scene.stash_id}
|
||||
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}
|
||||
disabled={!config.setCoverImage}
|
||||
exclude={
|
||||
excludedFields[fields.cover_image] || !config.setCoverImage
|
||||
}
|
||||
setExclude={(v) => setExcludedField(fields.cover_image, v)}
|
||||
>
|
||||
<a href={stashBoxURL} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={scene.images[0]}
|
||||
src={scene.image}
|
||||
alt=""
|
||||
className="align-self-center scene-image"
|
||||
/>
|
||||
</a>
|
||||
</OptionalField>
|
||||
</div>
|
||||
<div className="d-flex flex-column justify-content-center scene-metadata">
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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)}
|
||||
>
|
||||
{sceneTitle}
|
||||
{sceneTitleEl}
|
||||
</OptionalField>
|
||||
</h4>
|
||||
);
|
||||
};
|
||||
|
||||
{!isActive && (
|
||||
<>
|
||||
<h5>
|
||||
{scene?.studio?.name} • {scene?.date}
|
||||
</h5>
|
||||
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" },
|
||||
@@ -291,10 +412,13 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
)}
|
||||
: {scene?.performers?.map((p) => p.name).join(", ")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
{isActive && scene.date && (
|
||||
const maybeRenderDateField = () => {
|
||||
if (isActive && scene.date) {
|
||||
return (
|
||||
<h5>
|
||||
<OptionalField
|
||||
exclude={excludedFields[fields.date]}
|
||||
@@ -303,14 +427,13 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
{scene.date}
|
||||
</OptionalField>
|
||||
</h5>
|
||||
)}
|
||||
{getDurationStatus(scene, stashScene.file?.duration)}
|
||||
{getFingerprintStatus(scene, stashScene)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="d-flex flex-column">
|
||||
{scene.url && (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const maybeRenderURL = () => {
|
||||
if (scene.url) {
|
||||
return (
|
||||
<div className="scene-details">
|
||||
<OptionalField
|
||||
exclude={excludedFields[fields.url]}
|
||||
@@ -321,8 +444,13 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
</a>
|
||||
</OptionalField>
|
||||
</div>
|
||||
)}
|
||||
{scene.details && (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const maybeRenderDetails = () => {
|
||||
if (scene.details) {
|
||||
return (
|
||||
<div className="scene-details">
|
||||
<OptionalField
|
||||
exclude={excludedFields[fields.details]}
|
||||
@@ -331,25 +459,75 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
<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>
|
||||
{isActive && (
|
||||
<div className="col-lg-6">
|
||||
<StudioResult studio={scene.studio} setStudio={setStudio} />
|
||||
{scene.performers
|
||||
.filter((p) => p.gender !== "MALE" || showMales)
|
||||
.map((performer) => (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
setPerformer={(data: PerformerOperation) =>
|
||||
setPerformer(data, performer.stash_id)
|
||||
}
|
||||
key={`${scene.stash_id}${performer.stash_id}`}
|
||||
endpoint={endpoint}
|
||||
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}>
|
||||
@@ -358,7 +536,6 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<TagSelect
|
||||
isDisabled={!setTags}
|
||||
isMulti
|
||||
onSelect={(items) => {
|
||||
setTagIDs(items.map((i) => i.id));
|
||||
@@ -368,9 +545,8 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</div>
|
||||
{setTags &&
|
||||
scene.tags
|
||||
.filter((t) => !t.id)
|
||||
{scene.tags
|
||||
?.filter((t) => !t.stored_id)
|
||||
.map((t) => (
|
||||
<Badge
|
||||
className="tag-item"
|
||||
@@ -387,31 +563,99 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator card />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={isActive ? "col-lg-6" : ""}>
|
||||
<div className="row mx-0">
|
||||
{maybeRenderCoverImage()}
|
||||
<div className="d-flex flex-column justify-content-center scene-metadata">
|
||||
{renderTitle()}
|
||||
|
||||
{!isActive && (
|
||||
<>
|
||||
{renderStudioDate()}
|
||||
{renderPerformerList()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{maybeRenderDateField()}
|
||||
{getDurationStatus(scene, stashScene.file?.duration)}
|
||||
{getFingerprintStatus(scene, stashScene)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="d-flex flex-column">
|
||||
{maybeRenderStashBoxID()}
|
||||
{maybeRenderURL()}
|
||||
{maybeRenderDetails()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="col-lg-6">
|
||||
{maybeRenderStudioField()}
|
||||
{renderPerformerField()}
|
||||
{renderTagsField()}
|
||||
|
||||
<div className="row no-gutters mt-2 align-items-center justify-content-end">
|
||||
{error.message && (
|
||||
<strong className="mt-1 mr-2 text-danger text-right">
|
||||
<abbr title={error.details} className="mr-2">
|
||||
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="" />
|
||||
) : (
|
||||
<OperationButton operation={handleSave}>
|
||||
<FormattedMessage id="actions.save" />
|
||||
)}
|
||||
</Button>
|
||||
</OperationButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 { FormattedMessage, useIntl } from "react-intl";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
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 { ValidTypes } from "src/components/Shared/Select";
|
||||
import { IStashBoxStudio } from "./utils";
|
||||
import { OptionalField } from "./IncludeButton";
|
||||
|
||||
export type StudioOperation =
|
||||
| { type: "create"; data: IStashBoxStudio }
|
||||
| { type: "update"; data: GQL.SlimStudioDataFragment }
|
||||
| { type: "existing"; data: GQL.StudioDataFragment }
|
||||
| { type: "skip" };
|
||||
import { OptionalField } from "./IncludeButton";
|
||||
import { OperationButton } from "../Shared/OperationButton";
|
||||
|
||||
interface IStudioResultProps {
|
||||
studio: IStashBoxStudio | null;
|
||||
setStudio: Dispatch<SetStateAction<StudioOperation | undefined>>;
|
||||
studio: GQL.ScrapedStudio;
|
||||
selectedID: string | undefined;
|
||||
setSelectedID: (id: string | undefined) => void;
|
||||
onCreate: () => void;
|
||||
onLink?: () => Promise<void>;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
|
||||
const intl = useIntl();
|
||||
const [selectedStudio, setSelectedStudio] = useState<string | null>();
|
||||
const [modalVisible, showModal] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<
|
||||
"create" | "existing" | "skip" | undefined
|
||||
>();
|
||||
const { data: studioData } = GQL.useFindStudioQuery({
|
||||
variables: { id: studio?.id ?? "" },
|
||||
skip: !studio?.id,
|
||||
});
|
||||
const {
|
||||
data: stashIDData,
|
||||
loading: loadingStashID,
|
||||
} = GQL.useFindStudiosQuery({
|
||||
variables: {
|
||||
studio_filter: {
|
||||
stash_id: {
|
||||
value: studio?.stash_id ?? "no-stashid",
|
||||
modifier: GQL.CriterionModifier.Equals,
|
||||
},
|
||||
},
|
||||
},
|
||||
const StudioResult: React.FC<IStudioResultProps> = ({
|
||||
studio,
|
||||
selectedID,
|
||||
setSelectedID,
|
||||
onCreate,
|
||||
onLink,
|
||||
endpoint,
|
||||
}) => {
|
||||
const { data: studioData, loading: stashLoading } = GQL.useFindStudioQuery({
|
||||
variables: { id: studio.stored_id ?? "" },
|
||||
skip: !studio.stored_id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (stashIDData?.findStudios.studios?.[0])
|
||||
setStudio({
|
||||
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 matchedStudio = studioData?.findStudio;
|
||||
const matchedStashID = matchedStudio?.stash_ids.some(
|
||||
(stashID) => stashID.endpoint === endpoint && stashID.stash_id
|
||||
);
|
||||
|
||||
const handleStudioSelect = (newStudio: ValidTypes[]) => {
|
||||
if (newStudio.length) {
|
||||
setSelectedSource("existing");
|
||||
setSelectedStudio(newStudio[0].id);
|
||||
setStudio({
|
||||
type: "update",
|
||||
data: newStudio[0] as GQL.SlimStudioDataFragment,
|
||||
});
|
||||
const handleSelect = (studios: ValidTypes[]) => {
|
||||
if (studios.length) {
|
||||
setSelectedID(studios[0].id);
|
||||
} else {
|
||||
setSelectedSource(undefined);
|
||||
setSelectedStudio(null);
|
||||
setSelectedID(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStudioCreate = () => {
|
||||
if (!studio) return;
|
||||
setSelectedSource("create");
|
||||
setStudio({
|
||||
type: "create",
|
||||
data: studio,
|
||||
});
|
||||
showModal(false);
|
||||
const handleSkip = () => {
|
||||
setSelectedID(undefined);
|
||||
};
|
||||
|
||||
const handleStudioSkip = () => {
|
||||
setSelectedSource("skip");
|
||||
setStudio({ type: "skip" });
|
||||
};
|
||||
if (stashLoading) return <div>Loading studio</div>;
|
||||
|
||||
if (loadingStashID) return <div>Loading studio</div>;
|
||||
|
||||
if (stashIDData?.findStudios.studios.length) {
|
||||
if (matchedStudio && matchedStashID) {
|
||||
return (
|
||||
<div className="row no-gutters my-2">
|
||||
<div className="entity-name">
|
||||
<FormattedMessage
|
||||
id="countables.studios"
|
||||
values={{ count: stashIDData?.findStudios.studios.length }}
|
||||
/>
|
||||
:<b className="ml-2">{studio?.name}</b>
|
||||
<FormattedMessage id="countables.studios" values={{ count: 1 }} />:
|
||||
<b className="ml-2">{studio.name}</b>
|
||||
</div>
|
||||
<span className="ml-auto">
|
||||
<OptionalField
|
||||
exclude={selectedSource === "skip"}
|
||||
exclude={selectedID === undefined}
|
||||
setExclude={(v) =>
|
||||
v ? handleStudioSkip() : setSelectedSource("existing")
|
||||
v ? handleSkip() : setSelectedID(matchedStudio.id)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className="mr-2">
|
||||
<FormattedMessage id="component_tagger.verb_matched" />:
|
||||
</span>
|
||||
<b className="col-3 text-right">
|
||||
{stashIDData.findStudios.studios[0].name}
|
||||
</b>
|
||||
<b className="col-3 text-right">{matchedStudio.name}</b>
|
||||
</div>
|
||||
</OptionalField>
|
||||
</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 (
|
||||
<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">
|
||||
<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>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant={selectedSource === "create" ? "primary" : "secondary"}
|
||||
onClick={() => showModal(true)}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => onCreate()}>
|
||||
<FormattedMessage id="actions.create" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedSource === "skip" ? "primary" : "secondary"}
|
||||
onClick={() => handleStudioSkip()}
|
||||
onClick={() => handleSkip()}
|
||||
>
|
||||
<FormattedMessage id="actions.skip" />
|
||||
</Button>
|
||||
<StudioSelect
|
||||
ids={selectedStudio ? [selectedStudio] : []}
|
||||
onSelect={handleStudioSelect}
|
||||
ids={selectedID ? [selectedID] : []}
|
||||
onSelect={handleSelect}
|
||||
className={cx("studio-select", {
|
||||
"studio-select-active": selectedSource === "existing",
|
||||
})}
|
||||
isClearable={false}
|
||||
/>
|
||||
{maybeRenderLinkButton()}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import React, { 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 React, { useContext, useState } from "react";
|
||||
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 { 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 { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "./constants";
|
||||
import { TaggerList } from "./TaggerList";
|
||||
import { TaggerScene } from "./TaggerScene";
|
||||
import { SceneTaggerModals } from "./sceneTaggerModals";
|
||||
import { SceneSearchResults } from "./StashSearchResult";
|
||||
|
||||
interface ITaggerProps {
|
||||
scenes: GQL.SlimSceneDataFragment[];
|
||||
@@ -21,161 +17,220 @@ interface ITaggerProps {
|
||||
}
|
||||
|
||||
export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
|
||||
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
|
||||
const [{ data: config }, setConfig] = useLocalForage<ITaggerConfig>(
|
||||
LOCAL_FORAGE_KEY,
|
||||
initialConfig
|
||||
);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
|
||||
const clearSubmissionQueue = (endpoint: string) => {
|
||||
if (!config) return;
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
fingerprintQueue: {
|
||||
...config.fingerprintQueue,
|
||||
[endpoint]: [],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const [
|
||||
const {
|
||||
sources,
|
||||
setCurrentSource,
|
||||
currentSource,
|
||||
doSceneQuery,
|
||||
doSceneFragmentScrape,
|
||||
doMultiSceneFragmentScrape,
|
||||
stopMultiScrape,
|
||||
searchResults,
|
||||
loading,
|
||||
loadingMulti,
|
||||
multiError,
|
||||
submitFingerprints,
|
||||
{ loading: submittingFingerprints },
|
||||
] = GQL.useSubmitStashBoxFingerprintsMutation();
|
||||
pendingFingerprints,
|
||||
} = useContext(TaggerStateContext);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [hideUnmatched, setHideUnmatched] = useState(false);
|
||||
|
||||
const handleFingerprintSubmission = (endpoint: string) => {
|
||||
if (!config) return;
|
||||
const intl = useIntl();
|
||||
|
||||
return submitFingerprints({
|
||||
variables: {
|
||||
input: {
|
||||
stash_box_index: getEndpointIndex(endpoint),
|
||||
scene_ids: config?.fingerprintQueue[endpoint],
|
||||
},
|
||||
},
|
||||
}).then(() => {
|
||||
clearSubmissionQueue(endpoint);
|
||||
});
|
||||
};
|
||||
function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) {
|
||||
return queue
|
||||
? queue.makeLink(scene.id, { sceneIndex: index })
|
||||
: `/scenes/${scene.id}`;
|
||||
}
|
||||
|
||||
if (!config) return <LoadingIndicator />;
|
||||
function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value));
|
||||
}
|
||||
|
||||
const savedEndpointIndex =
|
||||
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) {
|
||||
function renderSourceSelector() {
|
||||
return (
|
||||
stashConfig?.general.stashBoxes.findIndex(
|
||||
(s) => s.endpoint === endpoint
|
||||
) ?? -1
|
||||
<Form.Group controlId="scraper">
|
||||
<Form.Label>
|
||||
<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) {
|
||||
return (await stashBoxSceneQuery(searchVal, selectedEndpointIndex)).data;
|
||||
function renderConfigButton() {
|
||||
return (
|
||||
<div className="ml-2">
|
||||
<Button onClick={() => setShowConfig(!showConfig)}>
|
||||
<Icon className="fa-fw" icon="cog" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const queueFingerprintSubmission = (sceneId: string, endpoint: string) => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
fingerprintQueue: {
|
||||
...config.fingerprintQueue,
|
||||
[endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId],
|
||||
},
|
||||
function renderScenes() {
|
||||
const filteredScenes = !hideUnmatched
|
||||
? scenes
|
||||
: scenes.filter((s) => searchResults[s.id]?.results?.length);
|
||||
|
||||
return filteredScenes.map((scene, index) => {
|
||||
const sceneLink = generateSceneLink(scene, index);
|
||||
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 getQueue = (endpoint: string) => {
|
||||
if (!config) return [];
|
||||
return config.fingerprintQueue[endpoint] ?? [];
|
||||
};
|
||||
|
||||
const fingerprintQueue = {
|
||||
queueFingerprintSubmission,
|
||||
getQueue,
|
||||
submitFingerprints: handleFingerprintSubmission,
|
||||
submittingFingerprints,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Manual
|
||||
show={showManual}
|
||||
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">
|
||||
<TaggerScene
|
||||
key={scene.id}
|
||||
loading={loading}
|
||||
scene={scene}
|
||||
url={sceneLink}
|
||||
errorMessage={errorMessage}
|
||||
doSceneQuery={
|
||||
currentSource?.supportSceneQuery
|
||||
? async (v) => {
|
||||
await doSceneQuery(scene.id, v);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
scrapeSceneFragment={
|
||||
currentSource?.supportSceneFragment
|
||||
? 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_config"
|
||||
id="component_tagger.verb_toggle_unmatched"
|
||||
values={{
|
||||
toggle: (
|
||||
<FormattedMessage
|
||||
id={`actions.${showConfig ? "hide" : "show"}`}
|
||||
id={`actions.${!hideUnmatched ? "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" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderSubmitFingerprintsButton() {
|
||||
if (pendingFingerprints.length) {
|
||||
return (
|
||||
<OperationButton
|
||||
className="ml-1"
|
||||
operation={submitFingerprints}
|
||||
disabled={loading || loadingMulti}
|
||||
>
|
||||
Settings.
|
||||
</HashLink>
|
||||
</h5>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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 React, { useState, useContext, PropsWithChildren } from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { Link } from "react-router-dom";
|
||||
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 StashSearchResult from "./StashSearchResult";
|
||||
import { ITaggerConfig } from "./constants";
|
||||
import {
|
||||
parsePath,
|
||||
IStashBoxScene,
|
||||
sortScenesByDuration,
|
||||
prepareQueryString,
|
||||
} from "./utils";
|
||||
import { parsePath, prepareQueryString } from "src/components/Tagger/utils";
|
||||
import { OperationButton } from "src/components/Shared/OperationButton";
|
||||
import { TaggerStateContext } from "./context";
|
||||
import { ScenePreview } from "../Scenes/SceneCard";
|
||||
|
||||
interface ITaggerSceneDetails {
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
@@ -25,7 +19,7 @@ const TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {
|
||||
const sorted = sortPerformers(scene.performers);
|
||||
|
||||
return (
|
||||
<div className="scene-details">
|
||||
<div className="original-scene-details">
|
||||
<Collapse in={open}>
|
||||
<div className="row">
|
||||
<div className="col col-lg-6">
|
||||
@@ -78,55 +72,29 @@ const TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export interface ISearchResult {
|
||||
results?: IStashBoxScene[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ITaggerScene {
|
||||
interface ITaggerScene {
|
||||
scene: GQL.SlimSceneDataFragment;
|
||||
url: string;
|
||||
config: ITaggerConfig;
|
||||
searchResult?: ISearchResult;
|
||||
hideUnmatched?: boolean;
|
||||
errorMessage?: string;
|
||||
doSceneQuery?: (queryString: string) => void;
|
||||
scrapeSceneFragment?: (scene: GQL.SlimSceneDataFragment) => void;
|
||||
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,
|
||||
url,
|
||||
config,
|
||||
searchResult,
|
||||
hideUnmatched,
|
||||
loading,
|
||||
doSceneQuery,
|
||||
taggedScene,
|
||||
tagScene,
|
||||
endpoint,
|
||||
queueFingerprintSubmission,
|
||||
createNewTag,
|
||||
scrapeSceneFragment,
|
||||
errorMessage,
|
||||
children,
|
||||
}) => {
|
||||
const [selectedResult, setSelectedResult] = useState<number>(0);
|
||||
const [excluded, setExcluded] = useState<Record<string, boolean>>({});
|
||||
const { config } = useContext(TaggerStateContext);
|
||||
const [queryString, setQueryString] = useState<string>("");
|
||||
const [queryLoading, setQueryLoading] = useState(false);
|
||||
|
||||
const queryString = useRef<string>("");
|
||||
|
||||
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 { paths, file } = parsePath(scene.path);
|
||||
const defaultQueryString = prepareQueryString(
|
||||
scene,
|
||||
paths,
|
||||
@@ -135,23 +103,24 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
|
||||
config.blacklist
|
||||
);
|
||||
|
||||
const hasStashIDs = scene.stash_ids.length > 0;
|
||||
const width = scene.file.width ? scene.file.width : 0;
|
||||
const height = scene.file.height ? scene.file.height : 0;
|
||||
const isPortrait = height > width;
|
||||
|
||||
function renderMainContent() {
|
||||
if (!taggedScene && hasStashIDs) {
|
||||
return (
|
||||
<div className="text-right">
|
||||
<h5 className="text-bold">
|
||||
<FormattedMessage id="component_tagger.results.match_failed_already_tagged" />
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
async function query() {
|
||||
if (!doSceneQuery) return;
|
||||
|
||||
try {
|
||||
setQueryLoading(true);
|
||||
await doSceneQuery(queryString || defaultQueryString);
|
||||
} finally {
|
||||
setQueryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!taggedScene && !hasStashIDs) {
|
||||
function renderQueryForm() {
|
||||
if (!doSceneQuery) return;
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroup.Prepend>
|
||||
@@ -161,46 +130,29 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
|
||||
</InputGroup.Prepend>
|
||||
<Form.Control
|
||||
className="text-input"
|
||||
defaultValue={queryString.current || defaultQueryString}
|
||||
value={queryString || defaultQueryString}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
queryString.current = e.currentTarget.value;
|
||||
setQueryString(e.currentTarget.value);
|
||||
}}
|
||||
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
|
||||
e.key === "Enter" &&
|
||||
doSceneQuery(queryString.current || defaultQueryString)
|
||||
e.key === "Enter" && query()
|
||||
}
|
||||
/>
|
||||
<InputGroup.Append>
|
||||
<Button
|
||||
<OperationButton
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
doSceneQuery(queryString.current || defaultQueryString)
|
||||
}
|
||||
operation={query}
|
||||
loading={queryLoading}
|
||||
setLoading={setQueryLoading}
|
||||
>
|
||||
<FormattedMessage id="actions.search" />
|
||||
</Button>
|
||||
</OperationButton>
|
||||
</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 maybeRenderStashLinks() {
|
||||
if (scene.stash_ids.length > 0) {
|
||||
const stashLinks = scene.stash_ids.map((stashID) => {
|
||||
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||
@@ -220,57 +172,11 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
|
||||
|
||||
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() {
|
||||
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 className="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>
|
||||
</div>
|
||||
<Link to={url} className="scene-link overflow-hidden">
|
||||
<TruncatedText
|
||||
text={`${originalDir}\u200B${file}${ext}`}
|
||||
lineCount={2}
|
||||
/>
|
||||
<TruncatedText text={scene.title ?? scene.path} lineCount={2} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-md-6 my-1 align-self-center">
|
||||
{renderMainContent()}
|
||||
<div className="sub-content text-right">{renderSubContent()}</div>
|
||||
<div className="col-md-6 my-1">
|
||||
<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>
|
||||
<TaggerSceneDetails scene={scene} />
|
||||
</div>
|
||||
{renderSearchResult()}
|
||||
{children}
|
||||
</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 DEFAULT_BLACKLIST = [
|
||||
"\\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 PerformerConfig from "./Config";
|
||||
import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants";
|
||||
import {
|
||||
IStashBoxPerformer,
|
||||
selectPerformers,
|
||||
filterPerformer,
|
||||
} from "../utils";
|
||||
import { IStashBoxPerformer, selectPerformers } from "../utils";
|
||||
import PerformerModal from "../PerformerModal";
|
||||
import { useUpdatePerformer } from "../queries";
|
||||
|
||||
@@ -171,22 +167,16 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
|
||||
|
||||
const updatePerformer = useUpdatePerformer();
|
||||
|
||||
const handlePerformerUpdate = async (
|
||||
imageIndex: number,
|
||||
excludedFields: string[]
|
||||
) => {
|
||||
const handlePerformerUpdate = async (input: GQL.PerformerCreateInput) => {
|
||||
const performerData = modalPerformer;
|
||||
setModalPerformer(undefined);
|
||||
if (performerData?.id) {
|
||||
const filteredData = filterPerformer(performerData, excludedFields);
|
||||
|
||||
const res = await updatePerformer({
|
||||
...filteredData,
|
||||
image: excludedFields.includes("image")
|
||||
? undefined
|
||||
: performerData.images[imageIndex],
|
||||
const updateData: GQL.PerformerUpdateInput = {
|
||||
id: performerData.id,
|
||||
});
|
||||
...input,
|
||||
};
|
||||
|
||||
const res = await updatePerformer(updateData);
|
||||
if (!res.data?.performerUpdate)
|
||||
setError({
|
||||
...error,
|
||||
@@ -200,7 +190,6 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
|
||||
},
|
||||
});
|
||||
}
|
||||
setModalPerformer(undefined);
|
||||
};
|
||||
|
||||
const renderPerformers = () =>
|
||||
@@ -351,7 +340,7 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
|
||||
closeModal={() => setModalPerformer(undefined)}
|
||||
modalVisible={modalPerformer !== undefined}
|
||||
performer={modalPerformer}
|
||||
handlePerformerCreate={handlePerformerUpdate}
|
||||
onSave={handlePerformerUpdate}
|
||||
excludedPerformerFields={config.excludedPerformerFields}
|
||||
icon="tags"
|
||||
header="Update Performer"
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { IStashBoxPerformer, filterPerformer } from "../utils";
|
||||
import { IStashBoxPerformer } from "../utils";
|
||||
import { useUpdatePerformer } from "../queries";
|
||||
import PerformerModal from "../PerformerModal";
|
||||
|
||||
@@ -34,21 +34,19 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
|
||||
const updatePerformer = useUpdatePerformer();
|
||||
|
||||
const handleSave = async (image: number, excludedFields: string[]) => {
|
||||
if (modalPerformer) {
|
||||
const performerData = filterPerformer(modalPerformer, excludedFields);
|
||||
const handleSave = async (input: GQL.PerformerCreateInput) => {
|
||||
const performerData = modalPerformer;
|
||||
if (performerData?.id) {
|
||||
setError({});
|
||||
setSaveState("Saving performer");
|
||||
setModalPerformer(undefined);
|
||||
|
||||
const res = await updatePerformer({
|
||||
...performerData,
|
||||
image: excludedFields.includes("image")
|
||||
? undefined
|
||||
: modalPerformer.images[image],
|
||||
stash_ids: [{ stash_id: modalPerformer.stash_id, endpoint }],
|
||||
id: performer.id,
|
||||
});
|
||||
const updateData: GQL.PerformerUpdateInput = {
|
||||
id: performerData.id,
|
||||
...input,
|
||||
};
|
||||
|
||||
const res = await updatePerformer(updateData);
|
||||
|
||||
if (!res?.data?.performerUpdate)
|
||||
setError({
|
||||
@@ -83,7 +81,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
closeModal={() => setModalPerformer(undefined)}
|
||||
modalVisible={modalPerformer !== undefined}
|
||||
performer={modalPerformer}
|
||||
handlePerformerCreate={handleSave}
|
||||
onSave={handleSave}
|
||||
icon="tags"
|
||||
header="Update Performer"
|
||||
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 {
|
||||
max-width: 1600px;
|
||||
|
||||
.tagger-container-header {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.scene-card-preview {
|
||||
border-radius: 3px;
|
||||
margin-bottom: 0;
|
||||
@@ -28,6 +33,12 @@
|
||||
padding: 1rem;
|
||||
|
||||
.scene-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.original-scene-details {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -65,11 +76,10 @@
|
||||
max-width: 14rem;
|
||||
min-width: 168px;
|
||||
object-fit: contain;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.scene-metadata {
|
||||
margin-right: 1rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.select-existing {
|
||||
@@ -230,11 +240,15 @@ li:not(.active) {
|
||||
|
||||
.optional-field {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
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 {
|
||||
color: #bfccd6;
|
||||
text-decoration: line-through;
|
||||
@@ -244,11 +258,12 @@ li.active .optional-field.excluded .scene-link {
|
||||
}
|
||||
}
|
||||
|
||||
li.active .scene-image-container {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
// li.active .scene-image-container {
|
||||
// margin-left: 1rem;
|
||||
// }
|
||||
|
||||
.scene-details {
|
||||
.scene-details,
|
||||
.original-scene-details {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
> .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;
|
||||
} else if (mode === "path") {
|
||||
s = [...paths, filename].join(" ");
|
||||
} else {
|
||||
} else if (mode === "dir" && paths.length) {
|
||||
s = paths[paths.length - 1];
|
||||
}
|
||||
blacklist.forEach((b) => {
|
||||
|
||||
@@ -214,6 +214,14 @@ export const useSceneStreams = (id: string) =>
|
||||
export const useFindImage = (id: string) =>
|
||||
GQL.useFindImageQuery({ variables: { id } });
|
||||
|
||||
export const queryFindPerformer = (id: string) =>
|
||||
client.query<GQL.FindPerformerQuery>({
|
||||
query: GQL.FindPerformerDocument,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
export const useFindPerformer = (id: string) => {
|
||||
const skip = id === "new";
|
||||
return GQL.useFindPerformerQuery({ variables: { id }, skip });
|
||||
@@ -222,6 +230,13 @@ export const useFindStudio = (id: string) => {
|
||||
const skip = id === "new";
|
||||
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) => {
|
||||
const skip = id === "new";
|
||||
return GQL.useFindMovieQuery({ variables: { id }, skip });
|
||||
|
||||
@@ -49,14 +49,30 @@ export const ToastProvider: React.FC = ({ children }) => {
|
||||
function createHookObject(toastFunc: (toast: IToast) => void) {
|
||||
return {
|
||||
success: toastFunc,
|
||||
error: (error: Error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error.message);
|
||||
error: (error: unknown) => {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
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: error.message ?? error.toString(),
|
||||
content: "Unknown error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(message);
|
||||
toastFunc({
|
||||
variant: "danger",
|
||||
header: "Error",
|
||||
content: message,
|
||||
});
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any, no-console */
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"scan": "Scan",
|
||||
"scrape_with": "Scrape with…",
|
||||
"scrape_query": "Scrape query",
|
||||
"scrape_scene_fragment": "Scrape by fragment",
|
||||
"search": "Search",
|
||||
"select_all": "Select All",
|
||||
"select_none": "Select None",
|
||||
@@ -73,6 +74,7 @@
|
||||
"set_image": "Set image…",
|
||||
"show": "Show",
|
||||
"skip": "Skip",
|
||||
"stop": "Stop",
|
||||
"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.",
|
||||
"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_tag_desc": "Attach tags to scene, either by overwriting or merging with existing tags on scene.",
|
||||
"set_tag_label": "Set tags",
|
||||
"source": "Source",
|
||||
"show_male_desc": "Toggle whether male performers will be available to tag.",
|
||||
"show_male_label": "Show male performers"
|
||||
},
|
||||
@@ -131,11 +134,13 @@
|
||||
"match_failed_already_tagged": "Scene already tagged",
|
||||
"match_failed_no_result": "No results found",
|
||||
"match_success": "Scene successfully tagged",
|
||||
"unnamed": "Unnamed",
|
||||
"duration_off": "Duration off by at least {number}s",
|
||||
"duration_unknown": "Duration unknown"
|
||||
},
|
||||
"verb_match_fp": "Match Fingerprints",
|
||||
"verb_matched": "Matched",
|
||||
"verb_scrape_all": "Scrape All",
|
||||
"verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}",
|
||||
"verb_toggle_config": "{toggle} {configuration}",
|
||||
"verb_toggle_unmatched": "{toggle} unmatched scenes"
|
||||
|
||||
Reference in New Issue
Block a user