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:
WithoutPants
2021-10-14 08:53:04 +11:00
committed by GitHub
parent 1217f3fbc1
commit 4eeef22c15
26 changed files with 2195 additions and 1497 deletions

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ New Features
* Generalised Tagger view to support tagging using supported scene scrapers. ([#1812](https://github.com/stashapp/stash/pull/1812))
* Added built-in `Auto Tag` scene scraper to match performers, studio and tags from filename - using AutoTag logic. ([#1817](https://github.com/stashapp/stash/pull/1817)) * Added built-in `Auto Tag` scene scraper to match performers, studio and tags from filename - using AutoTag logic. ([#1817](https://github.com/stashapp/stash/pull/1817))
* Added interface options to disable creating performers/studios/tags from dropdown selectors. ([#1814](https://github.com/stashapp/stash/pull/1814)) * Added interface options to disable creating performers/studios/tags from dropdown selectors. ([#1814](https://github.com/stashapp/stash/pull/1814))

View File

@@ -22,6 +22,7 @@ import { DeleteScenesDialog } from "./DeleteScenesDialog";
import { SceneGenerateDialog } from "./SceneGenerateDialog"; import { SceneGenerateDialog } from "./SceneGenerateDialog";
import { ExportDialog } from "../Shared/ExportDialog"; import { ExportDialog } from "../Shared/ExportDialog";
import { SceneCardsGrid } from "./SceneCardsGrid"; import { SceneCardsGrid } from "./SceneCardsGrid";
import { TaggerContext } from "../Tagger/context";
interface ISceneList { interface ISceneList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
@@ -253,5 +254,5 @@ export const SceneList: React.FC<ISceneList> = ({
); );
} }
return listData.template; return <TaggerContext>{listData.template}</TaggerContext>;
}; };

View File

@@ -6,6 +6,7 @@ interface ILoadingProps {
message?: string; message?: string;
inline?: boolean; inline?: boolean;
small?: boolean; small?: boolean;
card?: boolean;
} }
const CLASSNAME = "LoadingIndicator"; const CLASSNAME = "LoadingIndicator";
@@ -15,8 +16,9 @@ const LoadingIndicator: React.FC<ILoadingProps> = ({
message, message,
inline = false, inline = false,
small = false, small = false,
card = false,
}) => ( }) => (
<div className={cx(CLASSNAME, { inline, small })}> <div className={cx(CLASSNAME, { inline, small, "card-based": card })}>
<Spinner animation="border" role="status" size={small ? "sm" : undefined}> <Spinner animation="border" role="status" size={small ? "sm" : undefined}>
<span className="sr-only">Loading...</span> <span className="sr-only">Loading...</span>
</Spinner> </Spinner>

View 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>
);
};

View File

@@ -2,10 +2,13 @@
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 70vh;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
&:not(.card-based) {
height: 70vh;
}
&-message { &-message {
margin-top: 1rem; margin-top: 1rem;
} }

View File

@@ -1,4 +1,4 @@
import React, { Dispatch, useRef } from "react"; import React, { useRef, useContext } from "react";
import { import {
Badge, Badge,
Button, Button,
@@ -9,29 +9,18 @@ import {
} from "react-bootstrap"; } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Icon } from "src/components/Shared"; import { Icon } from "src/components/Shared";
import { useConfiguration } from "src/core/StashService"; import { ParseMode, TagOperation } from "./constants";
import { TaggerStateContext } from "./context";
import { ITaggerConfig, ParseMode, TagOperation } from "./constants";
interface IConfigProps { interface IConfigProps {
show: boolean; show: boolean;
config: ITaggerConfig;
setConfig: Dispatch<ITaggerConfig>;
} }
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => { const Config: React.FC<IConfigProps> = ({ show }) => {
const { config, setConfig } = useContext(TaggerStateContext);
const intl = useIntl(); const intl = useIntl();
const stashConfig = useConfiguration();
const blacklistRef = useRef<HTMLInputElement | null>(null); const blacklistRef = useRef<HTMLInputElement | null>(null);
const handleInstanceSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedEndpoint = e.currentTarget.value;
setConfig({
...config,
selectedEndpoint,
});
};
const removeBlacklist = (index: number) => { const removeBlacklist = (index: number) => {
setConfig({ setConfig({
...config, ...config,
@@ -55,8 +44,6 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
blacklistRef.current.value = ""; blacklistRef.current.value = "";
}; };
const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? [];
return ( return (
<Collapse in={show}> <Collapse in={show}>
<Card> <Card>
@@ -221,29 +208,6 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
</Button> </Button>
</Badge> </Badge>
))} ))}
<Form.Group
controlId="stash-box-endpoint"
className="align-items-center row no-gutters mt-4"
>
<Form.Label className="mr-4">
<FormattedMessage id="component_tagger.config.active_instance" />
</Form.Label>
<Form.Control
as="select"
value={config.selectedEndpoint}
className="col-md-4 col-6 input-control"
disabled={!stashBoxes.length}
onChange={handleInstanceSelect}
>
{!stashBoxes.length && <option>No instances found</option>}
{stashConfig.data?.configuration.general.stashBoxes.map((i) => (
<option value={i.endpoint} key={i.endpoint}>
{i.endpoint}
</option>
))}
</Form.Control>
</Form.Group>
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -27,6 +27,7 @@ export const IncludeExcludeButton: React.FC<IIncludeExcludeButton> = ({
interface IOptionalField { interface IOptionalField {
exclude: boolean; exclude: boolean;
title?: string;
disabled?: boolean; disabled?: boolean;
setExclude: (v: boolean) => void; setExclude: (v: boolean) => void;
} }
@@ -35,9 +36,13 @@ export const OptionalField: React.FC<IOptionalField> = ({
exclude, exclude,
setExclude, setExclude,
children, children,
}) => ( title,
<div className={`optional-field ${!exclude ? "included" : "excluded"}`}> }) => {
<IncludeExcludeButton exclude={exclude} setExclude={setExclude} /> return (
{children} <div className={`optional-field ${!exclude ? "included" : "excluded"}`}>
</div> <IncludeExcludeButton exclude={exclude} setExclude={setExclude} />
); {title && <span className="optional-field-title">{title}</span>}
<div className="optional-field-content">{children}</div>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import { useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import { IconName } from "@fortawesome/fontawesome-svg-core"; import { IconName } from "@fortawesome/fontawesome-svg-core";
@@ -11,26 +11,24 @@ import {
TruncatedText, TruncatedText,
} from "src/components/Shared"; } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { TextUtils } from "src/utils"; import { genderToString, stringToGender } from "src/utils/gender";
import { genderToString } from "src/utils/gender";
import { IStashBoxPerformer } from "./utils";
interface IPerformerModalProps { interface IPerformerModalProps {
performer: IStashBoxPerformer; performer: GQL.ScrapedScenePerformerDataFragment;
modalVisible: boolean; modalVisible: boolean;
closeModal: () => void; closeModal: () => void;
handlePerformerCreate: (imageIndex: number, excludedFields: string[]) => void; onSave: (input: GQL.PerformerCreateInput) => void;
excludedPerformerFields?: string[]; excludedPerformerFields?: string[];
header: string; header: string;
icon: IconName; icon: IconName;
create?: boolean; create?: boolean;
endpoint: string; endpoint?: string;
} }
const PerformerModal: React.FC<IPerformerModalProps> = ({ const PerformerModal: React.FC<IPerformerModalProps> = ({
modalVisible, modalVisible,
performer, performer,
handlePerformerCreate, onSave,
closeModal, closeModal,
excludedPerformerFields = [], excludedPerformerFields = [],
header, header,
@@ -39,6 +37,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
endpoint, endpoint,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const [imageIndex, setImageIndex] = useState(0); const [imageIndex, setImageIndex] = useState(0);
const [imageState, setImageState] = useState< const [imageState, setImageState] = useState<
"loading" | "error" | "loaded" | "empty" "loading" | "error" | "loaded" | "empty"
@@ -51,7 +50,7 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
) )
); );
const { images } = performer; const images = performer.images ?? [];
const changeImage = (index: number) => { const changeImage = (index: number) => {
setImageIndex(index); setImageIndex(index);
@@ -94,7 +93,9 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
<Icon icon={excluded[name] ? "times" : "check"} /> <Icon icon={excluded[name] ? "times" : "check"} />
</Button> </Button>
)} )}
<strong>{TextUtils.capitalize(name)}:</strong> <strong>
<FormattedMessage id={name} />:
</strong>
</div> </div>
{truncate ? ( {truncate ? (
<TruncatedText className="col-7" text={text} /> <TruncatedText className="col-7" text={text} />
@@ -104,19 +105,77 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
</div> </div>
); );
const base = endpoint.match(/https?:\/\/.*?\//)?.[0]; const base = endpoint?.match(/https?:\/\/.*?\//)?.[0];
const link = base ? `${base}performers/${performer.stash_id}` : undefined; const link = base
? `${base}performers/${performer.remote_site_id}`
: undefined;
function onSaveClicked() {
if (!performer.name) {
throw new Error("performer name must set");
}
const performerData: GQL.PerformerCreateInput = {
name: performer.name ?? "",
aliases: performer.aliases,
gender: stringToGender(performer.gender ?? undefined),
birthdate: performer.birthdate,
ethnicity: performer.ethnicity,
eye_color: performer.eye_color,
country: performer.country,
height: performer.height,
measurements: performer.measurements,
fake_tits: performer.fake_tits,
career_length: performer.career_length,
tattoos: performer.tattoos,
piercings: performer.piercings,
url: performer.url,
twitter: performer.twitter,
instagram: performer.instagram,
image: images.length > imageIndex ? images[imageIndex] : undefined,
details: performer.details,
death_date: performer.death_date,
hair_color: performer.hair_color,
weight: Number.parseFloat(performer.weight ?? "") ?? undefined,
};
if (Number.isNaN(performerData.weight ?? 0)) {
performerData.weight = undefined;
}
if (performer.tags) {
performerData.tag_ids = performer.tags
.map((t) => t.stored_id)
.filter((t) => t) as string[];
}
// stashid handling code
const remoteSiteID = performer.remote_site_id;
if (remoteSiteID && endpoint) {
performerData.stash_ids = [
{
endpoint,
stash_id: remoteSiteID,
},
];
}
// handle exclusions
Object.keys(performerData).forEach((k) => {
if (excludedPerformerFields.includes(k) || excluded[k]) {
(performerData as Record<string, unknown>)[k] = undefined;
}
});
onSave(performerData);
}
return ( return (
<Modal <Modal
show={modalVisible} show={modalVisible}
accept={{ accept={{
text: intl.formatMessage({ id: "actions.save" }), text: intl.formatMessage({ id: "actions.save" }),
onClick: () => onClick: onSaveClicked,
handlePerformerCreate(
imageIndex,
create ? [] : Object.keys(excluded).filter((key) => excluded[key])
),
}} }}
cancel={{ onClick: () => closeModal(), variant: "secondary" }} cancel={{ onClick: () => closeModal(), variant: "secondary" }}
onHide={() => closeModal()} onHide={() => closeModal()}
@@ -127,7 +186,10 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
<div className="row"> <div className="row">
<div className="col-7"> <div className="col-7">
{renderField("name", performer.name)} {renderField("name", performer.name)}
{renderField("gender", genderToString(performer.gender))} {renderField(
"gender",
performer.gender ? genderToString(performer.gender) : ""
)}
{renderField("birthdate", performer.birthdate)} {renderField("birthdate", performer.birthdate)}
{renderField("death_date", performer.death_date)} {renderField("death_date", performer.death_date)}
{renderField("ethnicity", performer.ethnicity)} {renderField("ethnicity", performer.ethnicity)}
@@ -142,6 +204,11 @@ const PerformerModal: React.FC<IPerformerModalProps> = ({
{renderField("career_length", performer.career_length)} {renderField("career_length", performer.career_length)}
{renderField("tattoos", performer.tattoos, false)} {renderField("tattoos", performer.tattoos, false)}
{renderField("piercings", performer.piercings, false)} {renderField("piercings", performer.piercings, false)}
{renderField("weight", performer.weight, false)}
{renderField("details", performer.details)}
{renderField("url", performer.url)}
{renderField("twitter", performer.twitter)}
{renderField("instagram", performer.instagram)}
{link && ( {link && (
<h6 className="mt-2"> <h6 className="mt-2">
<a href={link} target="_blank" rel="noopener noreferrer"> <a href={link} target="_blank" rel="noopener noreferrer">

View File

@@ -1,120 +1,60 @@
import React, { useEffect, useState } from "react"; import React from "react";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import { PerformerSelect } from "src/components/Shared"; import { Icon, PerformerSelect } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { ValidTypes } from "src/components/Shared/Select"; import { ValidTypes } from "src/components/Shared/Select";
import { IStashBoxPerformer, filterPerformer } from "./utils";
import PerformerModal from "./PerformerModal";
import { OptionalField } from "./IncludeButton"; import { OptionalField } from "./IncludeButton";
import { OperationButton } from "../Shared/OperationButton";
export type PerformerOperation =
| { type: "create"; data: IStashBoxPerformer }
| { type: "update"; data: GQL.SlimPerformerDataFragment }
| { type: "existing"; data: GQL.PerformerDataFragment }
| { type: "skip" };
export interface IPerformerOperations {
[x: string]: PerformerOperation;
}
interface IPerformerResultProps { interface IPerformerResultProps {
performer: IStashBoxPerformer; performer: GQL.ScrapedPerformer;
setPerformer: (data: PerformerOperation) => void; selectedID: string | undefined;
endpoint: string; setSelectedID: (id: string | undefined) => void;
onCreate: () => void;
onLink?: () => Promise<void>;
endpoint?: string;
} }
const PerformerResult: React.FC<IPerformerResultProps> = ({ const PerformerResult: React.FC<IPerformerResultProps> = ({
performer, performer,
setPerformer, selectedID,
setSelectedID,
onCreate,
onLink,
endpoint, endpoint,
}) => { }) => {
const [selectedPerformer, setSelectedPerformer] = useState<string | null>(); const {
const [selectedSource, setSelectedSource] = useState< data: performerData,
"create" | "existing" | "skip" | undefined loading: stashLoading,
>(); } = GQL.useFindPerformerQuery({
const [modalVisible, showModal] = useState(false); variables: { id: performer.stored_id ?? "" },
const { data: performerData } = GQL.useFindPerformerQuery({ skip: !performer.stored_id,
variables: { id: performer.id ?? "" },
skip: !performer.id,
}); });
const { data: stashData, loading: stashLoading } = GQL.useFindPerformersQuery(
{
variables: {
performer_filter: {
stash_id: {
value: performer.stash_id,
modifier: GQL.CriterionModifier.Equals,
},
},
},
}
);
useEffect(() => { const matchedPerformer = performerData?.findPerformer;
if (stashData?.findPerformers.performers.length) const matchedStashID = matchedPerformer?.stash_ids.some(
setPerformer({ (stashID) => stashID.endpoint === endpoint && stashID.stash_id
type: "existing", );
data: stashData.findPerformers.performers[0],
});
else if (performerData?.findPerformer) {
setSelectedPerformer(performerData.findPerformer.id);
setSelectedSource("existing");
setPerformer({
type: "update",
data: performerData.findPerformer,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stashData, performerData]);
const handlePerformerSelect = (performers: ValidTypes[]) => { const handlePerformerSelect = (performers: ValidTypes[]) => {
if (performers.length) { if (performers.length) {
setSelectedSource("existing"); setSelectedID(performers[0].id);
setSelectedPerformer(performers[0].id);
setPerformer({
type: "update",
data: performers[0] as GQL.SlimPerformerDataFragment,
});
} else { } else {
setSelectedSource(undefined); setSelectedID(undefined);
setSelectedPerformer(null);
} }
}; };
const handlePerformerCreate = (
imageIndex: number,
excludedFields: string[]
) => {
const selectedImage = performer.images[imageIndex];
const images = selectedImage ? [selectedImage] : [];
setSelectedSource("create");
setPerformer({
type: "create",
data: {
...filterPerformer(performer, excludedFields),
name: performer.name,
stash_id: performer.stash_id,
images,
},
});
showModal(false);
};
const handlePerformerSkip = () => { const handlePerformerSkip = () => {
setSelectedSource("skip"); setSelectedID(undefined);
setPerformer({
type: "skip",
});
}; };
if (stashLoading) return <div>Loading performer</div>; if (stashLoading) return <div>Loading performer</div>;
if (stashData?.findPerformers.performers?.[0]?.id) { if (matchedPerformer && matchedStashID) {
return ( return (
<div className="row no-gutters my-2"> <div className="row no-gutters my-2">
<div className="entity-name"> <div className="entity-name">
@@ -123,45 +63,48 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
</div> </div>
<span className="ml-auto"> <span className="ml-auto">
<OptionalField <OptionalField
exclude={selectedSource === "skip"} exclude={selectedID === undefined}
setExclude={(v) => setExclude={(v) =>
v ? handlePerformerSkip() : setSelectedSource("existing") v ? handlePerformerSkip() : setSelectedID(matchedPerformer.id)
} }
> >
<div> <div>
<span className="mr-2"> <span className="mr-2">
<FormattedMessage id="component_tagger.verb_matched" />: <FormattedMessage id="component_tagger.verb_matched" />:
</span> </span>
<b className="col-3 text-right"> <b className="col-3 text-right">{matchedPerformer.name}</b>
{stashData.findPerformers.performers[0].name}
</b>
</div> </div>
</OptionalField> </OptionalField>
</span> </span>
</div> </div>
); );
} }
function maybeRenderLinkButton() {
if (endpoint && onLink) {
return (
<OperationButton
variant="secondary"
disabled={selectedID === undefined}
operation={onLink}
hideChildrenWhenLoading
>
<Icon icon="save" />
</OperationButton>
);
}
}
const selectedSource = !selectedID ? "skip" : "existing";
return ( return (
<div className="row no-gutters align-items-center mt-2"> <div className="row no-gutters align-items-center mt-2">
<PerformerModal
closeModal={() => showModal(false)}
modalVisible={modalVisible}
performer={performer}
handlePerformerCreate={handlePerformerCreate}
icon="star"
header="Create Performer"
create
endpoint={endpoint}
/>
<div className="entity-name"> <div className="entity-name">
<FormattedMessage id="countables.performers" values={{ count: 1 }} />: <FormattedMessage id="countables.performers" values={{ count: 1 }} />:
<b className="ml-2">{performer.name}</b> <b className="ml-2">{performer.name}</b>
</div> </div>
<ButtonGroup> <ButtonGroup>
<Button <Button variant="secondary" onClick={() => onCreate()}>
variant={selectedSource === "create" ? "primary" : "secondary"}
onClick={() => showModal(true)}
>
<FormattedMessage id="actions.create" /> <FormattedMessage id="actions.create" />
</Button> </Button>
<Button <Button
@@ -171,13 +114,14 @@ const PerformerResult: React.FC<IPerformerResultProps> = ({
<FormattedMessage id="actions.skip" /> <FormattedMessage id="actions.skip" />
</Button> </Button>
<PerformerSelect <PerformerSelect
ids={selectedPerformer ? [selectedPerformer] : []} ids={selectedID ? [selectedID] : []}
onSelect={handlePerformerSelect} onSelect={handlePerformerSelect}
className={cx("performer-select", { className={cx("performer-select", {
"performer-select-active": selectedSource === "existing", "performer-select-active": selectedSource === "existing",
})} })}
isClearable={false} isClearable={false}
/> />
{maybeRenderLinkButton()}
</ButtonGroup> </ButtonGroup>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import React, { useState, useReducer, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import cx from "classnames"; import cx from "classnames";
import { Badge, Button, Col, Form, Row } from "react-bootstrap"; import { Badge, Button, Col, Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@@ -13,22 +13,30 @@ import {
} from "src/components/Shared"; } from "src/components/Shared";
import { FormUtils } from "src/utils"; import { FormUtils } from "src/utils";
import { uniq } from "lodash"; import { uniq } from "lodash";
import PerformerResult, { PerformerOperation } from "./PerformerResult"; import { blobToBase64 } from "base64-blob";
import StudioResult, { StudioOperation } from "./StudioResult"; import { stringToGender } from "src/utils/gender";
import { IStashBoxScene } from "./utils";
import { useTagScene } from "./taggerService";
import { TagOperation } from "./constants";
import { OptionalField } from "./IncludeButton"; import { OptionalField } from "./IncludeButton";
import { IScrapedScene, TaggerStateContext } from "./context";
import { OperationButton } from "../Shared/OperationButton";
import { SceneTaggerModalsState } from "./sceneTaggerModals";
import PerformerResult from "./PerformerResult";
import StudioResult from "./StudioResult";
const getDurationStatus = ( const getDurationStatus = (
scene: IStashBoxScene, scene: IScrapedScene,
stashDuration: number | undefined | null stashDuration: number | undefined | null
) => { ) => {
if (!stashDuration) return ""; if (!stashDuration) return "";
const durations = scene.fingerprints const durations =
.map((f) => f.duration) scene.fingerprints
.map((d) => Math.abs(d - stashDuration)); ?.map((f) => f.duration)
.map((d) => Math.abs(d - stashDuration)) ?? [];
const sceneDuration = scene.duration ?? 0;
if (!sceneDuration && durations.length === 0) return "";
const matchCount = durations.filter((duration) => duration <= 5).length; const matchCount = durations.filter((duration) => duration <= 5).length;
let match; let match;
@@ -39,7 +47,7 @@ const getDurationStatus = (
values={{ matchCount, durationsLength: durations.length }} values={{ matchCount, durationsLength: durations.length }}
/> />
); );
else if (Math.abs(scene.duration - stashDuration) < 5) else if (Math.abs(sceneDuration - stashDuration) < 5)
match = <FormattedMessage id="component_tagger.results.fp_matches" />; match = <FormattedMessage id="component_tagger.results.fp_matches" />;
if (match) if (match)
@@ -50,11 +58,8 @@ const getDurationStatus = (
</div> </div>
); );
if (!scene.duration && durations.length === 0)
return <FormattedMessage id="component_tagger.results.duration_unknown" />;
const minDiff = Math.min( const minDiff = Math.min(
Math.abs(scene.duration - stashDuration), Math.abs(sceneDuration - stashDuration),
...durations ...durations
); );
return ( return (
@@ -66,13 +71,13 @@ const getDurationStatus = (
}; };
const getFingerprintStatus = ( const getFingerprintStatus = (
scene: IStashBoxScene, scene: IScrapedScene,
stashScene: GQL.SlimSceneDataFragment stashScene: GQL.SlimSceneDataFragment
) => { ) => {
const checksumMatch = scene.fingerprints.some( const checksumMatch = scene.fingerprints?.some(
(f) => f.hash === stashScene.checksum || f.hash === stashScene.oshash (f) => f.hash === stashScene.checksum || f.hash === stashScene.oshash
); );
const phashMatch = scene.fingerprints.some( const phashMatch = scene.fingerprints?.some(
(f) => f.hash === stashScene.phash (f) => f.hash === stashScene.phash
); );
if (checksumMatch || phashMatch) if (checksumMatch || phashMatch)
@@ -94,55 +99,60 @@ const getFingerprintStatus = (
}; };
interface IStashSearchResultProps { interface IStashSearchResultProps {
scene: IStashBoxScene; scene: IScrapedScene;
stashScene: GQL.SlimSceneDataFragment; stashScene: GQL.SlimSceneDataFragment;
index: number;
isActive: boolean; isActive: boolean;
setActive: () => void;
showMales: boolean;
setScene: (scene: GQL.SlimSceneDataFragment) => void;
setCoverImage: boolean;
tagOperation: TagOperation;
setTags: boolean;
endpoint: string;
queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
createNewTag: (toCreate: GQL.ScrapedTag) => void;
excludedFields: Record<string, boolean>;
setExcludedFields: (v: Record<string, boolean>) => void;
} }
interface IPerformerReducerAction {
id: string;
data: PerformerOperation;
}
const performerReducer = (
state: Record<string, PerformerOperation>,
action: IPerformerReducerAction
) => ({ ...state, [action.id]: action.data });
const StashSearchResult: React.FC<IStashSearchResultProps> = ({ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
scene, scene,
stashScene, stashScene,
index,
isActive, isActive,
setActive,
showMales,
setScene,
setCoverImage,
tagOperation,
setTags,
endpoint,
queueFingerprintSubmission,
createNewTag,
excludedFields,
setExcludedFields,
}) => { }) => {
const intl = useIntl();
const {
config,
createNewTag,
createNewPerformer,
linkPerformer,
createNewStudio,
linkStudio,
resolveScene,
currentSource,
saveScene,
} = React.useContext(TaggerStateContext);
const performers = useMemo(
() =>
scene.performers?.filter((p) => {
if (!config.showMales) {
return (
!p.gender || stringToGender(p.gender, true) !== GQL.GenderEnum.Male
);
}
return true;
}) ?? [],
[config, scene]
);
const { createPerformerModal, createStudioModal } = React.useContext(
SceneTaggerModalsState
);
const getInitialTags = useCallback(() => { const getInitialTags = useCallback(() => {
const stashSceneTags = stashScene.tags.map((t) => t.id); const stashSceneTags = stashScene.tags.map((t) => t.id);
if (!setTags) { if (!config.setTags) {
return stashSceneTags; return stashSceneTags;
} }
const newTags = scene.tags.filter((t) => t.id).map((t) => t.id!); const { tagOperation } = config;
const newTags =
scene.tags?.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? [];
if (tagOperation === "overwrite") { if (tagOperation === "overwrite") {
return newTags; return newTags;
} }
@@ -151,56 +161,65 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
} }
throw new Error("unexpected tagOperation"); throw new Error("unexpected tagOperation");
}, [stashScene, tagOperation, scene, setTags]); }, [stashScene, scene, config]);
const [studio, setStudio] = useState<StudioOperation>(); const getInitialPerformers = useCallback(() => {
const [performers, dispatch] = useReducer(performerReducer, {}); return performers.map((p) => p.stored_id ?? undefined);
const [tagIDs, setTagIDs] = useState<string[]>(getInitialTags()); }, [performers]);
const [saveState, setSaveState] = useState<string>("");
const [error, setError] = useState<{ message?: string; details?: string }>( const getInitialStudio = useCallback(() => {
return scene.studio?.stored_id ?? stashScene.studio?.id;
}, [stashScene, scene]);
const [loading, setLoading] = useState(false);
const [excludedFields, setExcludedFields] = useState<Record<string, boolean>>(
{} {}
); );
const [tagIDs, setTagIDs] = useState<string[]>(getInitialTags());
const intl = useIntl(); // map of original performer to id
const [performerIDs, setPerformerIDs] = useState<(string | undefined)[]>(
getInitialPerformers()
);
const [studioID, setStudioID] = useState<string | undefined>(
getInitialStudio()
);
useEffect(() => { useEffect(() => {
setTagIDs(getInitialTags()); setTagIDs(getInitialTags());
}, [setTags, tagOperation, getInitialTags]); }, [getInitialTags]);
const tagScene = useTagScene( useEffect(() => {
{ setPerformerIDs(getInitialPerformers());
tagOperation, }, [getInitialPerformers]);
setCoverImage,
setTags,
},
setSaveState,
setError
);
function getExcludedFields() { useEffect(() => {
return Object.keys(excludedFields).filter((f) => excludedFields[f]); setStudioID(getInitialStudio());
} }, [getInitialStudio]);
async function handleSave() { useEffect(() => {
const updatedScene = await tagScene( async function doResolveScene() {
stashScene, try {
scene, setLoading(true);
studio, await resolveScene(stashScene.id, index, scene);
performers, } finally {
tagIDs, setLoading(false);
getExcludedFields(), }
endpoint }
);
if (updatedScene) setScene(updatedScene); if (isActive && !loading && !scene.resolved) {
doResolveScene();
}
}, [isActive, loading, stashScene, index, resolveScene, scene]);
queueFingerprintSubmission(stashScene.id, endpoint); const stashBoxURL = useMemo(() => {
} if (currentSource?.stashboxEndpoint && scene.remote_site_id) {
const endpoint = currentSource.stashboxEndpoint;
const setPerformer = ( const endpointBase = endpoint.match(/https?:\/\/.*?\//)?.[0];
performerData: PerformerOperation, return `${endpointBase}scenes/${scene.remote_site_id}`;
performerID: string }
) => dispatch({ id: performerID, data: performerData }); }, [currentSource, scene]);
const setExcludedField = (name: string, value: boolean) => const setExcludedField = (name: string, value: boolean) =>
setExcludedFields({ setExcludedFields({
@@ -208,33 +227,102 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
[name]: value, [name]: value,
}); });
const classname = cx("row mx-0 mt-2 search-result", { async function handleSave() {
"selected-result": isActive, const excludedFieldList = Object.keys(excludedFields).filter(
}); (f) => excludedFields[f]
);
const sceneTitle = scene.url ? ( function resolveField<T>(field: string, stashField: T, remoteField: T) {
<a if (excludedFieldList.includes(field)) {
href={scene.url} return stashField;
target="_blank" }
rel="noopener noreferrer"
className="scene-link"
>
<TruncatedText text={scene?.title} />
</a>
) : (
<TruncatedText text={scene?.title} />
);
const saveEnabled = return remoteField;
Object.keys(performers ?? []).length === }
scene.performers.filter((p) => p.gender !== "MALE" || showMales).length &&
Object.keys(performers ?? []).every((id) => performers?.[id].type) &&
saveState === "";
const endpointBase = endpoint.match(/https?:\/\/.*?\//)?.[0]; let imgData;
const stashBoxURL = endpointBase if (!excludedFields.cover_image && config.setCoverImage) {
? `${endpointBase}scenes/${scene.stash_id}` const imgurl = scene.image;
: ""; if (imgurl) {
const img = await fetch(imgurl, {
mode: "cors",
cache: "no-store",
});
if (img.status === 200) {
const blob = await img.blob();
// Sanity check on image size since bad images will fail
if (blob.size > 10000) imgData = await blobToBase64(blob);
}
}
}
const filteredPerformerIDs = performerIDs.filter(
(id) => id !== undefined
) as string[];
const sceneCreateInput: GQL.SceneUpdateInput = {
id: stashScene.id ?? "",
title: resolveField("title", stashScene.title, scene.title),
details: resolveField("details", stashScene.details, scene.details),
date: resolveField("date", stashScene.date, scene.date),
performer_ids:
filteredPerformerIDs.length === 0
? stashScene.performers.map((p) => p.id)
: filteredPerformerIDs,
studio_id: studioID,
cover_image: resolveField("cover_image", undefined, imgData),
url: resolveField("url", stashScene.url, scene.url),
tag_ids: tagIDs,
stash_ids: stashScene.stash_ids ?? [],
};
const includeStashID = !excludedFieldList.includes("stash_ids");
if (
includeStashID &&
currentSource?.stashboxEndpoint &&
scene.remote_site_id
) {
sceneCreateInput.stash_ids = [
...(stashScene?.stash_ids
.map((s) => {
return {
endpoint: s.endpoint,
stash_id: s.stash_id,
};
})
.filter((s) => s.endpoint !== currentSource.stashboxEndpoint) ?? []),
{
endpoint: currentSource.stashboxEndpoint,
stash_id: scene.remote_site_id,
},
];
}
await saveScene(sceneCreateInput, includeStashID);
}
function performerModalCallback(
toCreate?: GQL.PerformerCreateInput | undefined
) {
if (toCreate) {
createNewPerformer(toCreate);
}
}
function showPerformerModal(t: GQL.ScrapedPerformer) {
createPerformerModal(t, performerModalCallback);
}
function studioModalCallback(toCreate?: GQL.StudioCreateInput | undefined) {
if (toCreate) {
createNewStudio(toCreate);
}
}
function showStudioModal(t: GQL.ScrapedStudio) {
createStudioModal(t, studioModalCallback);
}
// constants to get around dot-notation eslint rule // constants to get around dot-notation eslint rule
const fields = { const fields = {
@@ -243,175 +331,331 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
date: "date", date: "date",
url: "url", url: "url",
details: "details", details: "details",
studio: "studio",
stash_ids: "stash_ids",
}; };
const maybeRenderCoverImage = () => {
if (scene.image) {
return (
<div className="scene-image-container">
<OptionalField
disabled={!config.setCoverImage}
exclude={
excludedFields[fields.cover_image] || !config.setCoverImage
}
setExclude={(v) => setExcludedField(fields.cover_image, v)}
>
<img
src={scene.image}
alt=""
className="align-self-center scene-image"
/>
</OptionalField>
</div>
);
}
};
const renderTitle = () => {
if (!scene.title) {
return (
<h4 className="text-muted">
<FormattedMessage id="component_tagger.results.unnamed" />
</h4>
);
}
const sceneTitleEl = scene.url ? (
<a
href={scene.url}
target="_blank"
rel="noopener noreferrer"
className="scene-link"
>
<TruncatedText text={scene.title} />
</a>
) : (
<TruncatedText text={scene.title} />
);
return (
<h4>
<OptionalField
exclude={excludedFields[fields.title]}
setExclude={(v) => setExcludedField(fields.title, v)}
>
{sceneTitleEl}
</OptionalField>
</h4>
);
};
function renderStudioDate() {
const text =
scene.studio && scene.date
? `${scene.studio.name}${scene.date}`
: `${scene.studio?.name ?? scene.date ?? ""}`;
if (text) {
return <h5>{text}</h5>;
}
}
const renderPerformerList = () => {
if (scene.performers?.length) {
return (
<div>
{intl.formatMessage(
{ id: "countables.performers" },
{ count: scene?.performers?.length }
)}
: {scene?.performers?.map((p) => p.name).join(", ")}
</div>
);
}
};
const maybeRenderDateField = () => {
if (isActive && scene.date) {
return (
<h5>
<OptionalField
exclude={excludedFields[fields.date]}
setExclude={(v) => setExcludedField(fields.date, v)}
>
{scene.date}
</OptionalField>
</h5>
);
}
};
const maybeRenderURL = () => {
if (scene.url) {
return (
<div className="scene-details">
<OptionalField
exclude={excludedFields[fields.url]}
setExclude={(v) => setExcludedField(fields.url, v)}
>
<a href={scene.url} target="_blank" rel="noopener noreferrer">
{scene.url}
</a>
</OptionalField>
</div>
);
}
};
const maybeRenderDetails = () => {
if (scene.details) {
return (
<div className="scene-details">
<OptionalField
exclude={excludedFields[fields.details]}
setExclude={(v) => setExcludedField(fields.details, v)}
>
<TruncatedText text={scene.details ?? ""} lineCount={3} />
</OptionalField>
</div>
);
}
};
const maybeRenderStashBoxID = () => {
if (scene.remote_site_id && stashBoxURL) {
return (
<div className="scene-details">
<OptionalField
exclude={excludedFields[fields.stash_ids]}
setExclude={(v) => setExcludedField(fields.stash_ids, v)}
>
<a href={stashBoxURL} target="_blank" rel="noopener noreferrer">
{scene.remote_site_id}
</a>
</OptionalField>
</div>
);
}
};
const maybeRenderStudioField = () => {
if (scene.studio) {
return (
<div className="mt-2">
<StudioResult
studio={scene.studio}
selectedID={studioID}
setSelectedID={(id) => setStudioID(id)}
onCreate={() => showStudioModal(scene.studio!)}
endpoint={currentSource?.stashboxEndpoint}
onLink={async () => {
await linkStudio(scene.studio!, studioID!);
}}
/>
</div>
);
}
};
function setPerformerID(performerIndex: number, id: string | undefined) {
const newPerformerIDs = [...performerIDs];
newPerformerIDs[performerIndex] = id;
setPerformerIDs(newPerformerIDs);
}
const renderPerformerField = () => (
<div className="mt-2">
<div>
<Form.Group controlId="performers">
{performers.map((performer, performerIndex) => (
<PerformerResult
performer={performer}
selectedID={performerIDs[performerIndex]}
setSelectedID={(id) => setPerformerID(performerIndex, id)}
onCreate={() => showPerformerModal(performer)}
onLink={async () => {
await linkPerformer(performer, performerIDs[performerIndex]!);
}}
endpoint={currentSource?.stashboxEndpoint}
key={`${performer.name ?? performer.remote_site_id ?? ""}`}
/>
))}
</Form.Group>
</div>
</div>
);
const renderTagsField = () => (
<div className="mt-2">
<div>
<Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({
title: `${intl.formatMessage({ id: "tags" })}:`,
})}
<Col sm={9} xl={12}>
<TagSelect
isMulti
onSelect={(items) => {
setTagIDs(items.map((i) => i.id));
}}
ids={tagIDs}
/>
</Col>
</Form.Group>
</div>
{scene.tags
?.filter((t) => !t.stored_id)
.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => {
createNewTag(t);
}}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon="plus" />
</Button>
</Badge>
))}
</div>
);
if (loading) {
return <LoadingIndicator card />;
}
return ( return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions <>
<li <div className={isActive ? "col-lg-6" : ""}>
className={`${classname} ${isActive && "active"}`} <div className="row mx-0">
key={scene.stash_id} {maybeRenderCoverImage()}
onClick={() => !isActive && setActive()}
>
<div className="col-lg-6">
<div className="row">
<div className="scene-image-container">
<OptionalField
exclude={excludedFields[fields.cover_image] || !setCoverImage}
disabled={!setCoverImage}
setExclude={(v) => setExcludedField(fields.cover_image, v)}
>
<a href={stashBoxURL} target="_blank" rel="noopener noreferrer">
<img
src={scene.images[0]}
alt=""
className="align-self-center scene-image"
/>
</a>
</OptionalField>
</div>
<div className="d-flex flex-column justify-content-center scene-metadata"> <div className="d-flex flex-column justify-content-center scene-metadata">
<h4> {renderTitle()}
<OptionalField
exclude={excludedFields[fields.title]}
setExclude={(v) => setExcludedField(fields.title, v)}
>
{sceneTitle}
</OptionalField>
</h4>
{!isActive && ( {!isActive && (
<> <>
<h5> {renderStudioDate()}
{scene?.studio?.name} {scene?.date} {renderPerformerList()}
</h5>
<div>
{intl.formatMessage(
{ id: "countables.performers" },
{ count: scene?.performers?.length }
)}
: {scene?.performers?.map((p) => p.name).join(", ")}
</div>
</> </>
)} )}
{isActive && scene.date && ( {maybeRenderDateField()}
<h5>
<OptionalField
exclude={excludedFields[fields.date]}
setExclude={(v) => setExcludedField(fields.date, v)}
>
{scene.date}
</OptionalField>
</h5>
)}
{getDurationStatus(scene, stashScene.file?.duration)} {getDurationStatus(scene, stashScene.file?.duration)}
{getFingerprintStatus(scene, stashScene)} {getFingerprintStatus(scene, stashScene)}
</div> </div>
</div> </div>
{isActive && ( {isActive && (
<div className="d-flex flex-column"> <div className="d-flex flex-column">
{scene.url && ( {maybeRenderStashBoxID()}
<div className="scene-details"> {maybeRenderURL()}
<OptionalField {maybeRenderDetails()}
exclude={excludedFields[fields.url]}
setExclude={(v) => setExcludedField(fields.url, v)}
>
<a href={scene.url} target="_blank" rel="noopener noreferrer">
{scene.url}
</a>
</OptionalField>
</div>
)}
{scene.details && (
<div className="scene-details">
<OptionalField
exclude={excludedFields[fields.details]}
setExclude={(v) => setExcludedField(fields.details, v)}
>
<TruncatedText text={scene.details ?? ""} lineCount={3} />
</OptionalField>
</div>
)}
</div> </div>
)} )}
</div> </div>
{isActive && ( {isActive && (
<div className="col-lg-6"> <div className="col-lg-6">
<StudioResult studio={scene.studio} setStudio={setStudio} /> {maybeRenderStudioField()}
{scene.performers {renderPerformerField()}
.filter((p) => p.gender !== "MALE" || showMales) {renderTagsField()}
.map((performer) => (
<PerformerResult
performer={performer}
setPerformer={(data: PerformerOperation) =>
setPerformer(data, performer.stash_id)
}
key={`${scene.stash_id}${performer.stash_id}`}
endpoint={endpoint}
/>
))}
<div className="mt-2">
<div>
<Form.Group controlId="tags" as={Row}>
{FormUtils.renderLabel({
title: `${intl.formatMessage({ id: "tags" })}:`,
})}
<Col sm={9} xl={12}>
<TagSelect
isDisabled={!setTags}
isMulti
onSelect={(items) => {
setTagIDs(items.map((i) => i.id));
}}
ids={tagIDs}
/>
</Col>
</Form.Group>
</div>
{setTags &&
scene.tags
.filter((t) => !t.id)
.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => {
createNewTag(t);
}}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon="plus" />
</Button>
</Badge>
))}
</div>
<div className="row no-gutters mt-2 align-items-center justify-content-end"> <div className="row no-gutters mt-2 align-items-center justify-content-end">
{error.message && ( <OperationButton operation={handleSave}>
<strong className="mt-1 mr-2 text-danger text-right"> <FormattedMessage id="actions.save" />
<abbr title={error.details} className="mr-2"> </OperationButton>
Error:
</abbr>
{error.message}
</strong>
)}
{saveState && (
<strong className="col-4 mt-1 mr-2 text-right">
{saveState}
</strong>
)}
<Button onClick={handleSave} disabled={!saveEnabled}>
{saveState ? (
<LoadingIndicator inline small message="" />
) : (
<FormattedMessage id="actions.save" />
)}
</Button>
</div> </div>
</div> </div>
)} )}
</li> </>
);
};
export interface ISceneSearchResults {
target: GQL.SlimSceneDataFragment;
scenes: GQL.ScrapedSceneDataFragment[];
}
export const SceneSearchResults: React.FC<ISceneSearchResults> = ({
target,
scenes,
}) => {
const [selectedResult, setSelectedResult] = useState<number | undefined>();
useEffect(() => {
if (!scenes) {
setSelectedResult(undefined);
}
}, [scenes]);
function getClassName(i: number) {
return cx("row mx-0 mt-2 search-result", {
"selected-result active": i === selectedResult,
});
}
return (
<ul className="pl-0 mt-3 mb-0">
{scenes.map((s, i) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key
<li
// eslint-disable-next-line react/no-array-index-key
key={i}
onClick={() => setSelectedResult(i)}
className={getClassName(i)}
>
<StashSearchResult
index={i}
isActive={i === selectedResult}
scene={s}
stashScene={target}
/>
</li>
))}
</ul>
); );
}; };

View 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;

View File

@@ -1,122 +1,75 @@
import React, { useEffect, useState, Dispatch, SetStateAction } from "react"; import React from "react";
import { Button, ButtonGroup } from "react-bootstrap"; import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage } from "react-intl";
import cx from "classnames"; import cx from "classnames";
import { Modal, StudioSelect } from "src/components/Shared"; import { Icon, StudioSelect } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { ValidTypes } from "src/components/Shared/Select"; import { ValidTypes } from "src/components/Shared/Select";
import { IStashBoxStudio } from "./utils";
import { OptionalField } from "./IncludeButton";
export type StudioOperation = import { OptionalField } from "./IncludeButton";
| { type: "create"; data: IStashBoxStudio } import { OperationButton } from "../Shared/OperationButton";
| { type: "update"; data: GQL.SlimStudioDataFragment }
| { type: "existing"; data: GQL.StudioDataFragment }
| { type: "skip" };
interface IStudioResultProps { interface IStudioResultProps {
studio: IStashBoxStudio | null; studio: GQL.ScrapedStudio;
setStudio: Dispatch<SetStateAction<StudioOperation | undefined>>; selectedID: string | undefined;
setSelectedID: (id: string | undefined) => void;
onCreate: () => void;
onLink?: () => Promise<void>;
endpoint?: string;
} }
const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => { const StudioResult: React.FC<IStudioResultProps> = ({
const intl = useIntl(); studio,
const [selectedStudio, setSelectedStudio] = useState<string | null>(); selectedID,
const [modalVisible, showModal] = useState(false); setSelectedID,
const [selectedSource, setSelectedSource] = useState< onCreate,
"create" | "existing" | "skip" | undefined onLink,
>(); endpoint,
const { data: studioData } = GQL.useFindStudioQuery({ }) => {
variables: { id: studio?.id ?? "" }, const { data: studioData, loading: stashLoading } = GQL.useFindStudioQuery({
skip: !studio?.id, variables: { id: studio.stored_id ?? "" },
}); skip: !studio.stored_id,
const {
data: stashIDData,
loading: loadingStashID,
} = GQL.useFindStudiosQuery({
variables: {
studio_filter: {
stash_id: {
value: studio?.stash_id ?? "no-stashid",
modifier: GQL.CriterionModifier.Equals,
},
},
},
}); });
useEffect(() => { const matchedStudio = studioData?.findStudio;
if (stashIDData?.findStudios.studios?.[0]) const matchedStashID = matchedStudio?.stash_ids.some(
setStudio({ (stashID) => stashID.endpoint === endpoint && stashID.stash_id
type: "existing", );
data: stashIDData.findStudios.studios[0],
});
else if (studioData?.findStudio) {
setSelectedSource("existing");
setSelectedStudio(studioData.findStudio.id);
setStudio({
type: "update",
data: studioData.findStudio,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stashIDData, studioData]);
const handleStudioSelect = (newStudio: ValidTypes[]) => { const handleSelect = (studios: ValidTypes[]) => {
if (newStudio.length) { if (studios.length) {
setSelectedSource("existing"); setSelectedID(studios[0].id);
setSelectedStudio(newStudio[0].id);
setStudio({
type: "update",
data: newStudio[0] as GQL.SlimStudioDataFragment,
});
} else { } else {
setSelectedSource(undefined); setSelectedID(undefined);
setSelectedStudio(null);
} }
}; };
const handleStudioCreate = () => { const handleSkip = () => {
if (!studio) return; setSelectedID(undefined);
setSelectedSource("create");
setStudio({
type: "create",
data: studio,
});
showModal(false);
}; };
const handleStudioSkip = () => { if (stashLoading) return <div>Loading studio</div>;
setSelectedSource("skip");
setStudio({ type: "skip" });
};
if (loadingStashID) return <div>Loading studio</div>; if (matchedStudio && matchedStashID) {
if (stashIDData?.findStudios.studios.length) {
return ( return (
<div className="row no-gutters my-2"> <div className="row no-gutters my-2">
<div className="entity-name"> <div className="entity-name">
<FormattedMessage <FormattedMessage id="countables.studios" values={{ count: 1 }} />:
id="countables.studios" <b className="ml-2">{studio.name}</b>
values={{ count: stashIDData?.findStudios.studios.length }}
/>
:<b className="ml-2">{studio?.name}</b>
</div> </div>
<span className="ml-auto"> <span className="ml-auto">
<OptionalField <OptionalField
exclude={selectedSource === "skip"} exclude={selectedID === undefined}
setExclude={(v) => setExclude={(v) =>
v ? handleStudioSkip() : setSelectedSource("existing") v ? handleSkip() : setSelectedID(matchedStudio.id)
} }
> >
<div> <div>
<span className="mr-2"> <span className="mr-2">
<FormattedMessage id="component_tagger.verb_matched" />: <FormattedMessage id="component_tagger.verb_matched" />:
</span> </span>
<b className="col-3 text-right"> <b className="col-3 text-right">{matchedStudio.name}</b>
{stashIDData.findStudios.studios[0].name}
</b>
</div> </div>
</OptionalField> </OptionalField>
</span> </span>
@@ -124,60 +77,48 @@ const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
); );
} }
function maybeRenderLinkButton() {
if (endpoint && onLink) {
return (
<OperationButton
variant="secondary"
disabled={selectedID === undefined}
operation={onLink}
hideChildrenWhenLoading
>
<Icon icon="save" />
</OperationButton>
);
}
}
const selectedSource = !selectedID ? "skip" : "existing";
return ( return (
<div className="row no-gutters align-items-center mt-2"> <div className="row no-gutters align-items-center mt-2">
<Modal
show={modalVisible}
accept={{
text: intl.formatMessage({ id: "actions.save" }),
onClick: handleStudioCreate,
}}
cancel={{ onClick: () => showModal(false), variant: "secondary" }}
>
<div className="row">
<strong className="col-2">
<FormattedMessage id="name" />:
</strong>
<span className="col-10">{studio?.name}</span>
</div>
<div className="row">
<strong className="col-2">
<FormattedMessage id="url" />:
</strong>
<span className="col-10">{studio?.url ?? ""}</span>
</div>
<div className="row">
<strong className="col-2">Logo:</strong>
<span className="col-10">
<img src={studio?.image ?? ""} alt="" />
</span>
</div>
</Modal>
<div className="entity-name"> <div className="entity-name">
<FormattedMessage id="studios" />:<b className="ml-2">{studio?.name}</b> <FormattedMessage id="countables.studios" values={{ count: 1 }} />:
<b className="ml-2">{studio.name}</b>
</div> </div>
<ButtonGroup> <ButtonGroup>
<Button <Button variant="secondary" onClick={() => onCreate()}>
variant={selectedSource === "create" ? "primary" : "secondary"}
onClick={() => showModal(true)}
>
<FormattedMessage id="actions.create" /> <FormattedMessage id="actions.create" />
</Button> </Button>
<Button <Button
variant={selectedSource === "skip" ? "primary" : "secondary"} variant={selectedSource === "skip" ? "primary" : "secondary"}
onClick={() => handleStudioSkip()} onClick={() => handleSkip()}
> >
<FormattedMessage id="actions.skip" /> <FormattedMessage id="actions.skip" />
</Button> </Button>
<StudioSelect <StudioSelect
ids={selectedStudio ? [selectedStudio] : []} ids={selectedID ? [selectedID] : []}
onSelect={handleStudioSelect} onSelect={handleSelect}
className={cx("studio-select", { className={cx("studio-select", {
"studio-select-active": selectedSource === "existing", "studio-select-active": selectedSource === "existing",
})} })}
isClearable={false} isClearable={false}
/> />
{maybeRenderLinkButton()}
</ButtonGroup> </ButtonGroup>
</div> </div>
); );

View File

@@ -1,19 +1,15 @@
import React, { useState } from "react"; import React, { useContext, useState } from "react";
import { Button } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { HashLink } from "react-router-hash-link";
import { useLocalForage } from "src/hooks";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator } from "src/components/Shared";
import { stashBoxSceneQuery } from "src/core/StashService";
import { Manual } from "src/components/Help/Manual";
import { SceneQueue } from "src/models/sceneQueue"; import { SceneQueue } from "src/models/sceneQueue";
import { ConfigurationContext } from "src/hooks/Config"; import { Button, Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Icon, LoadingIndicator } from "src/components/Shared";
import { OperationButton } from "src/components/Shared/OperationButton";
import { TaggerStateContext } from "./context";
import Config from "./Config"; import Config from "./Config";
import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "./constants"; import { TaggerScene } from "./TaggerScene";
import { TaggerList } from "./TaggerList"; import { SceneTaggerModals } from "./sceneTaggerModals";
import { SceneSearchResults } from "./StashSearchResult";
interface ITaggerProps { interface ITaggerProps {
scenes: GQL.SlimSceneDataFragment[]; scenes: GQL.SlimSceneDataFragment[];
@@ -21,161 +17,220 @@ interface ITaggerProps {
} }
export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => { export const Tagger: React.FC<ITaggerProps> = ({ scenes, queue }) => {
const { configuration: stashConfig } = React.useContext(ConfigurationContext); const {
const [{ data: config }, setConfig] = useLocalForage<ITaggerConfig>( sources,
LOCAL_FORAGE_KEY, setCurrentSource,
initialConfig currentSource,
); doSceneQuery,
const [showConfig, setShowConfig] = useState(false); doSceneFragmentScrape,
const [showManual, setShowManual] = useState(false); doMultiSceneFragmentScrape,
stopMultiScrape,
const clearSubmissionQueue = (endpoint: string) => { searchResults,
if (!config) return; loading,
loadingMulti,
setConfig({ multiError,
...config,
fingerprintQueue: {
...config.fingerprintQueue,
[endpoint]: [],
},
});
};
const [
submitFingerprints, submitFingerprints,
{ loading: submittingFingerprints }, pendingFingerprints,
] = GQL.useSubmitStashBoxFingerprintsMutation(); } = useContext(TaggerStateContext);
const [showConfig, setShowConfig] = useState(false);
const [hideUnmatched, setHideUnmatched] = useState(false);
const handleFingerprintSubmission = (endpoint: string) => { const intl = useIntl();
if (!config) return;
return submitFingerprints({ function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) {
variables: { return queue
input: { ? queue.makeLink(scene.id, { sceneIndex: index })
stash_box_index: getEndpointIndex(endpoint), : `/scenes/${scene.id}`;
scene_ids: config?.fingerprintQueue[endpoint], }
},
},
}).then(() => {
clearSubmissionQueue(endpoint);
});
};
if (!config) return <LoadingIndicator />; function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value));
}
const savedEndpointIndex = function renderSourceSelector() {
stashConfig?.general.stashBoxes.findIndex(
(s) => s.endpoint === config.selectedEndpoint
) ?? -1;
const selectedEndpointIndex =
savedEndpointIndex === -1 && stashConfig?.general.stashBoxes.length
? 0
: savedEndpointIndex;
const selectedEndpoint =
stashConfig?.general.stashBoxes[selectedEndpointIndex];
function getEndpointIndex(endpoint: string) {
return ( return (
stashConfig?.general.stashBoxes.findIndex( <Form.Group controlId="scraper">
(s) => s.endpoint === endpoint <Form.Label>
) ?? -1 <FormattedMessage id="component_tagger.config.source" />
</Form.Label>
<div>
<Form.Control
as="select"
value={currentSource?.id}
className="input-control"
disabled={loading || !sources.length}
onChange={handleSourceSelect}
>
{!sources.length && <option>No scraper sources</option>}
{sources.map((i) => (
<option value={i.id} key={i.id}>
{i.displayName}
</option>
))}
</Form.Control>
</div>
</Form.Group>
); );
} }
async function doBoxSearch(searchVal: string) { function renderConfigButton() {
return (await stashBoxSceneQuery(searchVal, selectedEndpointIndex)).data; return (
<div className="ml-2">
<Button onClick={() => setShowConfig(!showConfig)}>
<Icon className="fa-fw" icon="cog" />
</Button>
</div>
);
} }
const queueFingerprintSubmission = (sceneId: string, endpoint: string) => { function renderScenes() {
if (!config) return; const filteredScenes = !hideUnmatched
setConfig({ ? scenes
...config, : scenes.filter((s) => searchResults[s.id]?.results?.length);
fingerprintQueue: {
...config.fingerprintQueue,
[endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId],
},
});
};
const getQueue = (endpoint: string) => { return filteredScenes.map((scene, index) => {
if (!config) return []; const sceneLink = generateSceneLink(scene, index);
return config.fingerprintQueue[endpoint] ?? []; let errorMessage: string | undefined;
}; const searchResult = searchResults[scene.id];
if (searchResult?.error) {
errorMessage = searchResult.error;
} else if (searchResult && searchResult.results?.length === 0) {
errorMessage = intl.formatMessage({
id: "component_tagger.results.match_failed_no_result",
});
}
const fingerprintQueue = { return (
queueFingerprintSubmission, <TaggerScene
getQueue, key={scene.id}
submitFingerprints: handleFingerprintSubmission, loading={loading}
submittingFingerprints, scene={scene}
}; url={sceneLink}
errorMessage={errorMessage}
return ( doSceneQuery={
<> currentSource?.supportSceneQuery
<Manual ? async (v) => {
show={showManual} await doSceneQuery(scene.id, v);
onClose={() => setShowManual(false)}
defaultActiveTab="Tagger.md"
/>
<div className="tagger-container mx-md-auto">
{selectedEndpointIndex !== -1 && selectedEndpoint ? (
<>
<div className="row mb-2 no-gutters">
<Button onClick={() => setShowConfig(!showConfig)} variant="link">
<FormattedMessage
id="component_tagger.verb_toggle_config"
values={{
toggle: (
<FormattedMessage
id={`actions.${showConfig ? "hide" : "show"}`}
/>
),
configuration: <FormattedMessage id="configuration" />,
}}
/>
</Button>
<Button
className="ml-auto"
onClick={() => setShowManual(true)}
title="Help"
variant="link"
>
<FormattedMessage id="help" />
</Button>
</div>
<Config config={config} setConfig={setConfig} show={showConfig} />
<TaggerList
scenes={scenes}
queue={queue}
config={config}
selectedEndpoint={{
endpoint: selectedEndpoint.endpoint,
index: selectedEndpointIndex,
}}
queryScene={doBoxSearch}
fingerprintQueue={fingerprintQueue}
/>
</>
) : (
<div className="my-4">
<h3 className="text-center mt-4">
To use the scene tagger a stash-box instance needs to be
configured.
</h3>
<h5 className="text-center">
Please see{" "}
<HashLink
to="/settings?tab=configuration#stashbox"
scroll={(el) =>
el.scrollIntoView({ behavior: "smooth", block: "center" })
} }
> : undefined
Settings. }
</HashLink> scrapeSceneFragment={
</h5> currentSource?.supportSceneFragment
</div> ? async () => {
await doSceneFragmentScrape(scene.id);
}
: undefined
}
>
{searchResult && searchResult.results?.length ? (
<SceneSearchResults scenes={searchResult.results} target={scene} />
) : undefined}
</TaggerScene>
);
});
}
const toggleHideUnmatchedScenes = () => {
setHideUnmatched(!hideUnmatched);
};
function maybeRenderShowHideUnmatchedButton() {
if (Object.keys(searchResults).length) {
return (
<Button onClick={toggleHideUnmatchedScenes}>
<FormattedMessage
id="component_tagger.verb_toggle_unmatched"
values={{
toggle: (
<FormattedMessage
id={`actions.${!hideUnmatched ? "hide" : "show"}`}
/>
),
}}
/>
</Button>
);
}
}
function maybeRenderSubmitFingerprintsButton() {
if (pendingFingerprints.length) {
return (
<OperationButton
className="ml-1"
operation={submitFingerprints}
disabled={loading || loadingMulti}
>
<span>
<FormattedMessage
id="component_tagger.verb_submit_fp"
values={{ fpCount: pendingFingerprints.length }}
/>
</span>
</OperationButton>
);
}
}
function renderFragmentScrapeButton() {
if (!currentSource?.supportSceneFragment) {
return;
}
if (loadingMulti) {
return (
<Button
className="ml-1"
variant="danger"
onClick={() => {
stopMultiScrape();
}}
>
<LoadingIndicator message="" inline small />
<span className="ml-2">
{intl.formatMessage({ id: "actions.stop" })}
</span>
</Button>
);
}
return (
<div className="ml-1">
<OperationButton
disabled={loading}
operation={async () => {
await doMultiSceneFragmentScrape(scenes.map((s) => s.id));
}}
>
{intl.formatMessage({ id: "component_tagger.verb_scrape_all" })}
</OperationButton>
{multiError && (
<>
<br />
<b className="text-danger">{multiError}</b>
</>
)} )}
</div> </div>
</> );
}
return (
<SceneTaggerModals>
<div className="tagger-container mx-md-auto">
<div className="tagger-container-header">
<div className="d-flex justify-content-between align-items-center flex-wrap">
<div className="w-auto">{renderSourceSelector()}</div>
<div className="d-flex">
{maybeRenderShowHideUnmatchedButton()}
{maybeRenderSubmitFingerprintsButton()}
{renderFragmentScrapeButton()}
{renderConfigButton()}
</div>
</div>
<Config show={showConfig} />
</div>
<div>{renderScenes()}</div>
</div>
</SceneTaggerModals>
); );
}; };

View File

@@ -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>
);
};

View File

@@ -1,20 +1,14 @@
import React, { useRef, useState } from "react"; import React, { useState, useContext, PropsWithChildren } from "react";
import { Button, Collapse, Form, InputGroup } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FormattedMessage } from "react-intl";
import { ScenePreview } from "src/components/Scenes/SceneCard";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { Link } from "react-router-dom";
import { Icon, TagLink, TruncatedText } from "src/components/Shared"; import { Icon, TagLink, TruncatedText } from "src/components/Shared";
import { Button, Collapse, Form, InputGroup } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { sortPerformers } from "src/core/performers"; import { sortPerformers } from "src/core/performers";
import StashSearchResult from "./StashSearchResult"; import { parsePath, prepareQueryString } from "src/components/Tagger/utils";
import { ITaggerConfig } from "./constants"; import { OperationButton } from "src/components/Shared/OperationButton";
import { import { TaggerStateContext } from "./context";
parsePath, import { ScenePreview } from "../Scenes/SceneCard";
IStashBoxScene,
sortScenesByDuration,
prepareQueryString,
} from "./utils";
interface ITaggerSceneDetails { interface ITaggerSceneDetails {
scene: GQL.SlimSceneDataFragment; scene: GQL.SlimSceneDataFragment;
@@ -25,7 +19,7 @@ const TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {
const sorted = sortPerformers(scene.performers); const sorted = sortPerformers(scene.performers);
return ( return (
<div className="scene-details"> <div className="original-scene-details">
<Collapse in={open}> <Collapse in={open}>
<div className="row"> <div className="row">
<div className="col col-lg-6"> <div className="col col-lg-6">
@@ -78,55 +72,29 @@ const TaggerSceneDetails: React.FC<ITaggerSceneDetails> = ({ scene }) => {
); );
}; };
export interface ISearchResult { interface ITaggerScene {
results?: IStashBoxScene[];
error?: string;
}
export interface ITaggerScene {
scene: GQL.SlimSceneDataFragment; scene: GQL.SlimSceneDataFragment;
url: string; url: string;
config: ITaggerConfig; errorMessage?: string;
searchResult?: ISearchResult; doSceneQuery?: (queryString: string) => void;
hideUnmatched?: boolean; scrapeSceneFragment?: (scene: GQL.SlimSceneDataFragment) => void;
loading?: boolean; loading?: boolean;
doSceneQuery: (queryString: string) => void;
taggedScene?: Partial<GQL.SlimSceneDataFragment>;
tagScene: (scene: Partial<GQL.SlimSceneDataFragment>) => void;
endpoint: string;
queueFingerprintSubmission: (sceneId: string, endpoint: string) => void;
createNewTag: (toCreate: GQL.ScrapedTag) => void;
} }
export const TaggerScene: React.FC<ITaggerScene> = ({ export const TaggerScene: React.FC<PropsWithChildren<ITaggerScene>> = ({
scene, scene,
url, url,
config,
searchResult,
hideUnmatched,
loading, loading,
doSceneQuery, doSceneQuery,
taggedScene, scrapeSceneFragment,
tagScene, errorMessage,
endpoint, children,
queueFingerprintSubmission,
createNewTag,
}) => { }) => {
const [selectedResult, setSelectedResult] = useState<number>(0); const { config } = useContext(TaggerStateContext);
const [excluded, setExcluded] = useState<Record<string, boolean>>({}); const [queryString, setQueryString] = useState<string>("");
const [queryLoading, setQueryLoading] = useState(false);
const queryString = useRef<string>(""); const { paths, file } = parsePath(scene.path);
const searchResults = searchResult?.results ?? [];
const searchError = searchResult?.error;
const emptyResults =
searchResult && searchResult.results && searchResult.results.length === 0;
const { paths, file, ext } = parsePath(scene.path);
const originalDir = scene.path.slice(
0,
scene.path.length - file.length - ext.length
);
const defaultQueryString = prepareQueryString( const defaultQueryString = prepareQueryString(
scene, scene,
paths, paths,
@@ -135,72 +103,56 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
config.blacklist config.blacklist
); );
const hasStashIDs = scene.stash_ids.length > 0;
const width = scene.file.width ? scene.file.width : 0; const width = scene.file.width ? scene.file.width : 0;
const height = scene.file.height ? scene.file.height : 0; const height = scene.file.height ? scene.file.height : 0;
const isPortrait = height > width; const isPortrait = height > width;
function renderMainContent() { async function query() {
if (!taggedScene && hasStashIDs) { if (!doSceneQuery) return;
return (
<div className="text-right">
<h5 className="text-bold">
<FormattedMessage id="component_tagger.results.match_failed_already_tagged" />
</h5>
</div>
);
}
if (!taggedScene && !hasStashIDs) { try {
return ( setQueryLoading(true);
<InputGroup> await doSceneQuery(queryString || defaultQueryString);
<InputGroup.Prepend> } finally {
<InputGroup.Text> setQueryLoading(false);
<FormattedMessage id="component_tagger.noun_query" />
</InputGroup.Text>
</InputGroup.Prepend>
<Form.Control
className="text-input"
defaultValue={queryString.current || defaultQueryString}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
queryString.current = e.currentTarget.value;
}}
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
e.key === "Enter" &&
doSceneQuery(queryString.current || defaultQueryString)
}
/>
<InputGroup.Append>
<Button
disabled={loading}
onClick={() =>
doSceneQuery(queryString.current || defaultQueryString)
}
>
<FormattedMessage id="actions.search" />
</Button>
</InputGroup.Append>
</InputGroup>
);
}
if (taggedScene) {
return (
<div className="d-flex flex-column text-right">
<h5>
<FormattedMessage id="component_tagger.results.match_success" />
</h5>
<h6>
<Link className="bold" to={url}>
{taggedScene.title}
</Link>
</h6>
</div>
);
} }
} }
function renderSubContent() { function renderQueryForm() {
if (!doSceneQuery) return;
return (
<InputGroup>
<InputGroup.Prepend>
<InputGroup.Text>
<FormattedMessage id="component_tagger.noun_query" />
</InputGroup.Text>
</InputGroup.Prepend>
<Form.Control
className="text-input"
value={queryString || defaultQueryString}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setQueryString(e.currentTarget.value);
}}
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
e.key === "Enter" && query()
}
/>
<InputGroup.Append>
<OperationButton
disabled={loading}
operation={query}
loading={queryLoading}
setLoading={setQueryLoading}
>
<FormattedMessage id="actions.search" />
</OperationButton>
</InputGroup.Append>
</InputGroup>
);
}
function maybeRenderStashLinks() {
if (scene.stash_ids.length > 0) { if (scene.stash_ids.length > 0) {
const stashLinks = scene.stash_ids.map((stashID) => { const stashLinks = scene.stash_ids.map((stashID) => {
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
@@ -220,57 +172,11 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
return link; return link;
}); });
return <>{stashLinks}</>; return <div className="mt-2 sub-content text-right">{stashLinks}</div>;
}
if (searchError) {
return <div className="text-danger font-weight-bold">{searchError}</div>;
}
if (emptyResults) {
return (
<div className="text-danger font-weight-bold">
<FormattedMessage id="component_tagger.results.match_failed_no_result" />
</div>
);
} }
} }
function renderSearchResult() { return (
if (searchResults.length > 0 && !taggedScene) {
return (
<ul className="pl-0 mt-3 mb-0">
{sortScenesByDuration(
searchResults,
scene.file.duration ?? undefined
).map(
(sceneResult, i) =>
sceneResult && (
<StashSearchResult
key={sceneResult.stash_id}
showMales={config.showMales}
stashScene={scene}
scene={sceneResult}
isActive={selectedResult === i}
setActive={() => setSelectedResult(i)}
setCoverImage={config.setCoverImage}
tagOperation={config.tagOperation}
setTags={config.setTags}
setScene={tagScene}
endpoint={endpoint}
queueFingerprintSubmission={queueFingerprintSubmission}
createNewTag={createNewTag}
excludedFields={excluded}
setExcludedFields={(v) => setExcluded(v)}
/>
)
)}
</ul>
);
}
}
return hideUnmatched && emptyResults ? null : (
<div key={scene.id} className="mt-3 search-item"> <div key={scene.id} className="mt-3 search-item">
<div className="row"> <div className="row">
<div className="col col-lg-6 overflow-hidden align-items-center d-flex flex-column flex-sm-row"> <div className="col col-lg-6 overflow-hidden align-items-center d-flex flex-column flex-sm-row">
@@ -285,19 +191,33 @@ export const TaggerScene: React.FC<ITaggerScene> = ({
</Link> </Link>
</div> </div>
<Link to={url} className="scene-link overflow-hidden"> <Link to={url} className="scene-link overflow-hidden">
<TruncatedText <TruncatedText text={scene.title ?? scene.path} lineCount={2} />
text={`${originalDir}\u200B${file}${ext}`}
lineCount={2}
/>
</Link> </Link>
</div> </div>
<div className="col-md-6 my-1 align-self-center"> <div className="col-md-6 my-1">
{renderMainContent()} <div>
<div className="sub-content text-right">{renderSubContent()}</div> {renderQueryForm()}
{scrapeSceneFragment ? (
<div className="mt-2 text-right">
<OperationButton
disabled={loading}
operation={async () => {
await scrapeSceneFragment(scene);
}}
>
<FormattedMessage id="actions.scrape_scene_fragment" />
</OperationButton>
</div>
) : undefined}
</div>
{errorMessage ? (
<div className="text-danger font-weight-bold">{errorMessage}</div>
) : undefined}
{maybeRenderStashLinks()}
</div> </div>
<TaggerSceneDetails scene={scene} /> <TaggerSceneDetails scene={scene} />
</div> </div>
{renderSearchResult()} {children}
</div> </div>
); );
}; };

View File

@@ -1,3 +1,17 @@
import { ScraperSourceInput } from "src/core/generated-graphql";
export const STASH_BOX_PREFIX = "stashbox:";
export const SCRAPER_PREFIX = "scraper:";
export interface ITaggerSource {
id: string;
stashboxEndpoint?: string;
sourceInput: ScraperSourceInput;
displayName: string;
supportSceneQuery?: boolean;
supportSceneFragment?: boolean;
}
export const LOCAL_FORAGE_KEY = "tagger"; export const LOCAL_FORAGE_KEY = "tagger";
export const DEFAULT_BLACKLIST = [ export const DEFAULT_BLACKLIST = [
"\\sXXX\\s", "\\sXXX\\s",

View 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>
);
};

View File

@@ -18,11 +18,7 @@ import { ConfigurationContext } from "src/hooks/Config";
import StashSearchResult from "./StashSearchResult"; import StashSearchResult from "./StashSearchResult";
import PerformerConfig from "./Config"; import PerformerConfig from "./Config";
import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants"; import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants";
import { import { IStashBoxPerformer, selectPerformers } from "../utils";
IStashBoxPerformer,
selectPerformers,
filterPerformer,
} from "../utils";
import PerformerModal from "../PerformerModal"; import PerformerModal from "../PerformerModal";
import { useUpdatePerformer } from "../queries"; import { useUpdatePerformer } from "../queries";
@@ -171,22 +167,16 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
const updatePerformer = useUpdatePerformer(); const updatePerformer = useUpdatePerformer();
const handlePerformerUpdate = async ( const handlePerformerUpdate = async (input: GQL.PerformerCreateInput) => {
imageIndex: number,
excludedFields: string[]
) => {
const performerData = modalPerformer; const performerData = modalPerformer;
setModalPerformer(undefined); setModalPerformer(undefined);
if (performerData?.id) { if (performerData?.id) {
const filteredData = filterPerformer(performerData, excludedFields); const updateData: GQL.PerformerUpdateInput = {
const res = await updatePerformer({
...filteredData,
image: excludedFields.includes("image")
? undefined
: performerData.images[imageIndex],
id: performerData.id, id: performerData.id,
}); ...input,
};
const res = await updatePerformer(updateData);
if (!res.data?.performerUpdate) if (!res.data?.performerUpdate)
setError({ setError({
...error, ...error,
@@ -200,7 +190,6 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
}, },
}); });
} }
setModalPerformer(undefined);
}; };
const renderPerformers = () => const renderPerformers = () =>
@@ -351,7 +340,7 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
closeModal={() => setModalPerformer(undefined)} closeModal={() => setModalPerformer(undefined)}
modalVisible={modalPerformer !== undefined} modalVisible={modalPerformer !== undefined}
performer={modalPerformer} performer={modalPerformer}
handlePerformerCreate={handlePerformerUpdate} onSave={handlePerformerUpdate}
excludedPerformerFields={config.excludedPerformerFields} excludedPerformerFields={config.excludedPerformerFields}
icon="tags" icon="tags"
header="Update Performer" header="Update Performer"

View File

@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Button } from "react-bootstrap"; import { Button } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { IStashBoxPerformer, filterPerformer } from "../utils"; import { IStashBoxPerformer } from "../utils";
import { useUpdatePerformer } from "../queries"; import { useUpdatePerformer } from "../queries";
import PerformerModal from "../PerformerModal"; import PerformerModal from "../PerformerModal";
@@ -34,21 +34,19 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
const updatePerformer = useUpdatePerformer(); const updatePerformer = useUpdatePerformer();
const handleSave = async (image: number, excludedFields: string[]) => { const handleSave = async (input: GQL.PerformerCreateInput) => {
if (modalPerformer) { const performerData = modalPerformer;
const performerData = filterPerformer(modalPerformer, excludedFields); if (performerData?.id) {
setError({}); setError({});
setSaveState("Saving performer"); setSaveState("Saving performer");
setModalPerformer(undefined); setModalPerformer(undefined);
const res = await updatePerformer({ const updateData: GQL.PerformerUpdateInput = {
...performerData, id: performerData.id,
image: excludedFields.includes("image") ...input,
? undefined };
: modalPerformer.images[image],
stash_ids: [{ stash_id: modalPerformer.stash_id, endpoint }], const res = await updatePerformer(updateData);
id: performer.id,
});
if (!res?.data?.performerUpdate) if (!res?.data?.performerUpdate)
setError({ setError({
@@ -83,7 +81,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
closeModal={() => setModalPerformer(undefined)} closeModal={() => setModalPerformer(undefined)}
modalVisible={modalPerformer !== undefined} modalVisible={modalPerformer !== undefined}
performer={modalPerformer} performer={modalPerformer}
handlePerformerCreate={handleSave} onSave={handleSave}
icon="tags" icon="tags"
header="Update Performer" header="Update Performer"
excludedPerformerFields={excludedPerformerFields} excludedPerformerFields={excludedPerformerFields}

View 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>
);
};

View File

@@ -1,6 +1,11 @@
.tagger-container { .tagger-container {
max-width: 1600px; max-width: 1600px;
.tagger-container-header {
background-color: rgba(0, 0, 0, 0);
padding-bottom: 0;
}
.scene-card-preview { .scene-card-preview {
border-radius: 3px; border-radius: 3px;
margin-bottom: 0; margin-bottom: 0;
@@ -28,6 +33,12 @@
padding: 1rem; padding: 1rem;
.scene-details { .scene-details {
display: flex;
flex-direction: column;
width: 100%;
}
.original-scene-details {
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -65,11 +76,10 @@
max-width: 14rem; max-width: 14rem;
min-width: 168px; min-width: 168px;
object-fit: contain; object-fit: contain;
padding-right: 1rem;
} }
.scene-metadata { .scene-metadata {
margin-right: 1rem; margin-left: 1rem;
} }
.select-existing { .select-existing {
@@ -230,11 +240,15 @@ li:not(.active) {
.optional-field { .optional-field {
align-items: center; align-items: center;
display: flex; display: inline-flex;
flex-direction: row; flex-direction: row;
} }
li.active .optional-field.excluded, li.active .optional-field.missing .optional-field-content {
color: #bfccd6;
}
li.active .optional-field.excluded .optional-field-content,
li.active .optional-field.excluded .scene-link { li.active .optional-field.excluded .scene-link {
color: #bfccd6; color: #bfccd6;
text-decoration: line-through; text-decoration: line-through;
@@ -244,11 +258,12 @@ li.active .optional-field.excluded .scene-link {
} }
} }
li.active .scene-image-container { // li.active .scene-image-container {
margin-left: 1rem; // margin-left: 1rem;
} // }
.scene-details { .scene-details,
.original-scene-details {
margin-top: 0.5rem; margin-top: 0.5rem;
> .row { > .row {

View File

@@ -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;
}

View File

@@ -102,7 +102,7 @@ export function prepareQueryString(
s = filename; s = filename;
} else if (mode === "path") { } else if (mode === "path") {
s = [...paths, filename].join(" "); s = [...paths, filename].join(" ");
} else { } else if (mode === "dir" && paths.length) {
s = paths[paths.length - 1]; s = paths[paths.length - 1];
} }
blacklist.forEach((b) => { blacklist.forEach((b) => {

View File

@@ -214,6 +214,14 @@ export const useSceneStreams = (id: string) =>
export const useFindImage = (id: string) => export const useFindImage = (id: string) =>
GQL.useFindImageQuery({ variables: { id } }); GQL.useFindImageQuery({ variables: { id } });
export const queryFindPerformer = (id: string) =>
client.query<GQL.FindPerformerQuery>({
query: GQL.FindPerformerDocument,
variables: {
id,
},
});
export const useFindPerformer = (id: string) => { export const useFindPerformer = (id: string) => {
const skip = id === "new"; const skip = id === "new";
return GQL.useFindPerformerQuery({ variables: { id }, skip }); return GQL.useFindPerformerQuery({ variables: { id }, skip });
@@ -222,6 +230,13 @@ export const useFindStudio = (id: string) => {
const skip = id === "new"; const skip = id === "new";
return GQL.useFindStudioQuery({ variables: { id }, skip }); return GQL.useFindStudioQuery({ variables: { id }, skip });
}; };
export const queryFindStudio = (id: string) =>
client.query<GQL.FindStudioQuery>({
query: GQL.FindStudioDocument,
variables: {
id,
},
});
export const useFindMovie = (id: string) => { export const useFindMovie = (id: string) => {
const skip = id === "new"; const skip = id === "new";
return GQL.useFindMovieQuery({ variables: { id }, skip }); return GQL.useFindMovieQuery({ variables: { id }, skip });

View File

@@ -49,14 +49,30 @@ export const ToastProvider: React.FC = ({ children }) => {
function createHookObject(toastFunc: (toast: IToast) => void) { function createHookObject(toastFunc: (toast: IToast) => void) {
return { return {
success: toastFunc, success: toastFunc,
error: (error: Error) => { error: (error: unknown) => {
// eslint-disable-next-line no-console /* eslint-disable @typescript-eslint/no-explicit-any, no-console */
console.error(error.message); let message: string;
if (error instanceof Error) {
message = error.message ?? error.toString();
} else if ((error as any).toString) {
message = (error as any).toString();
} else {
console.error(error);
toastFunc({
variant: "danger",
header: "Error",
content: "Unknown error",
});
return;
}
console.error(message);
toastFunc({ toastFunc({
variant: "danger", variant: "danger",
header: "Error", header: "Error",
content: error.message ?? error.toString(), content: message,
}); });
/* eslint-enable @typescript-eslint/no-explicit-any, no-console */
}, },
}; };
} }

View File

@@ -62,6 +62,7 @@
"scan": "Scan", "scan": "Scan",
"scrape_with": "Scrape with…", "scrape_with": "Scrape with…",
"scrape_query": "Scrape query", "scrape_query": "Scrape query",
"scrape_scene_fragment": "Scrape by fragment",
"search": "Search", "search": "Search",
"select_all": "Select All", "select_all": "Select All",
"select_none": "Select None", "select_none": "Select None",
@@ -73,6 +74,7 @@
"set_image": "Set image…", "set_image": "Set image…",
"show": "Show", "show": "Show",
"skip": "Skip", "skip": "Skip",
"stop": "Stop",
"tasks": { "tasks": {
"clean_confirm_message": "Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.", "clean_confirm_message": "Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.",
"dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.", "dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.",
@@ -119,6 +121,7 @@
"set_cover_label": "Set scene cover image", "set_cover_label": "Set scene cover image",
"set_tag_desc": "Attach tags to scene, either by overwriting or merging with existing tags on scene.", "set_tag_desc": "Attach tags to scene, either by overwriting or merging with existing tags on scene.",
"set_tag_label": "Set tags", "set_tag_label": "Set tags",
"source": "Source",
"show_male_desc": "Toggle whether male performers will be available to tag.", "show_male_desc": "Toggle whether male performers will be available to tag.",
"show_male_label": "Show male performers" "show_male_label": "Show male performers"
}, },
@@ -131,11 +134,13 @@
"match_failed_already_tagged": "Scene already tagged", "match_failed_already_tagged": "Scene already tagged",
"match_failed_no_result": "No results found", "match_failed_no_result": "No results found",
"match_success": "Scene successfully tagged", "match_success": "Scene successfully tagged",
"unnamed": "Unnamed",
"duration_off": "Duration off by at least {number}s", "duration_off": "Duration off by at least {number}s",
"duration_unknown": "Duration unknown" "duration_unknown": "Duration unknown"
}, },
"verb_match_fp": "Match Fingerprints", "verb_match_fp": "Match Fingerprints",
"verb_matched": "Matched", "verb_matched": "Matched",
"verb_scrape_all": "Scrape All",
"verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", "verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}",
"verb_toggle_config": "{toggle} {configuration}", "verb_toggle_config": "{toggle} {configuration}",
"verb_toggle_unmatched": "{toggle} unmatched scenes" "verb_toggle_unmatched": "{toggle} unmatched scenes"