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
* Generalised Tagger view to support tagging using supported scene scrapers. ([#1812](https://github.com/stashapp/stash/pull/1812))
* Added built-in `Auto Tag` scene scraper to match performers, studio and tags from filename - using AutoTag logic. ([#1817](https://github.com/stashapp/stash/pull/1817))
* Added interface options to disable creating performers/studios/tags from dropdown selectors. ([#1814](https://github.com/stashapp/stash/pull/1814))

View File

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

View File

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

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;
display: flex;
flex-direction: column;
height: 70vh;
justify-content: center;
width: 100%;
&:not(.card-based) {
height: 70vh;
}
&-message {
margin-top: 1rem;
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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;
} else if (mode === "path") {
s = [...paths, filename].join(" ");
} else {
} else if (mode === "dir" && paths.length) {
s = paths[paths.length - 1];
}
blacklist.forEach((b) => {

View File

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

View File

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

View File

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