diff --git a/ui/v2.5/src/components/Changelog/versions/v0110.md b/ui/v2.5/src/components/Changelog/versions/v0110.md index d6b8f5c67..5cee669d0 100644 --- a/ui/v2.5/src/components/Changelog/versions/v0110.md +++ b/ui/v2.5/src/components/Changelog/versions/v0110.md @@ -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)) diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 8f64035ec..ec3b68dd3 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -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 = ({ ); } - return listData.template; + return {listData.template}; }; diff --git a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx index d87defbcf..f52498f0b 100644 --- a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx +++ b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx @@ -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 = ({ message, inline = false, small = false, + card = false, }) => ( -
+
Loading... diff --git a/ui/v2.5/src/components/Shared/OperationButton.tsx b/ui/v2.5/src/components/Shared/OperationButton.tsx new file mode 100644 index 000000000..7cdc83119 --- /dev/null +++ b/ui/v2.5/src/components/Shared/OperationButton.tsx @@ -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; + loading?: boolean; + hideChildrenWhenLoading?: boolean; + setLoading?: (v: boolean) => void; +} + +export const OperationButton: React.FC = (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 ( + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index bff6a5907..2c16e3ff7 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -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; } diff --git a/ui/v2.5/src/components/Tagger/Config.tsx b/ui/v2.5/src/components/Tagger/Config.tsx index 6faa452ed..8c8662d76 100644 --- a/ui/v2.5/src/components/Tagger/Config.tsx +++ b/ui/v2.5/src/components/Tagger/Config.tsx @@ -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; } -const Config: React.FC = ({ show, config, setConfig }) => { +const Config: React.FC = ({ show }) => { + const { config, setConfig } = useContext(TaggerStateContext); const intl = useIntl(); - const stashConfig = useConfiguration(); const blacklistRef = useRef(null); - const handleInstanceSelect = (e: React.ChangeEvent) => { - const selectedEndpoint = e.currentTarget.value; - setConfig({ - ...config, - selectedEndpoint, - }); - }; - const removeBlacklist = (index: number) => { setConfig({ ...config, @@ -55,8 +44,6 @@ const Config: React.FC = ({ show, config, setConfig }) => { blacklistRef.current.value = ""; }; - const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? []; - return ( @@ -221,29 +208,6 @@ const Config: React.FC = ({ show, config, setConfig }) => { ))} - - - - - - - {!stashBoxes.length && } - {stashConfig.data?.configuration.general.stashBoxes.map((i) => ( - - ))} - -
diff --git a/ui/v2.5/src/components/Tagger/IncludeButton.tsx b/ui/v2.5/src/components/Tagger/IncludeButton.tsx index 4292dfa23..bc0e12ab6 100644 --- a/ui/v2.5/src/components/Tagger/IncludeButton.tsx +++ b/ui/v2.5/src/components/Tagger/IncludeButton.tsx @@ -27,6 +27,7 @@ export const IncludeExcludeButton: React.FC = ({ interface IOptionalField { exclude: boolean; + title?: string; disabled?: boolean; setExclude: (v: boolean) => void; } @@ -35,9 +36,13 @@ export const OptionalField: React.FC = ({ exclude, setExclude, children, -}) => ( -
- - {children} -
-); + title, +}) => { + return ( +
+ + {title && {title}} +
{children}
+
+ ); +}; diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 913cb94d9..f16f11e58 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -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 = ({ modalVisible, performer, - handlePerformerCreate, + onSave, closeModal, excludedPerformerFields = [], header, @@ -39,6 +37,7 @@ const PerformerModal: React.FC = ({ 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 = ({ ) ); - const { images } = performer; + const images = performer.images ?? []; const changeImage = (index: number) => { setImageIndex(index); @@ -94,7 +93,9 @@ const PerformerModal: React.FC = ({ )} - {TextUtils.capitalize(name)}: + + : + {truncate ? ( @@ -104,19 +105,77 @@ const PerformerModal: React.FC = ({ ); - 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)[k] = undefined; + } + }); + + onSave(performerData); + } return ( - 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 = ({
{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 = ({ {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 && (
diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx index 0f0726842..07b615cc7 100755 --- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -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; + endpoint?: string; } const PerformerResult: React.FC = ({ performer, - setPerformer, + selectedID, + setSelectedID, + onCreate, + onLink, endpoint, }) => { - const [selectedPerformer, setSelectedPerformer] = useState(); - 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
Loading performer
; - if (stashData?.findPerformers.performers?.[0]?.id) { + if (matchedPerformer && matchedStashID) { return (
@@ -123,45 +63,48 @@ const PerformerResult: React.FC = ({
- v ? handlePerformerSkip() : setSelectedSource("existing") + v ? handlePerformerSkip() : setSelectedID(matchedPerformer.id) } >
: - - {stashData.findPerformers.performers[0].name} - + {matchedPerformer.name}
); } + + function maybeRenderLinkButton() { + if (endpoint && onLink) { + return ( + + + + ); + } + } + + const selectedSource = !selectedID ? "skip" : "existing"; + return (
- showModal(false)} - modalVisible={modalVisible} - performer={performer} - handlePerformerCreate={handlePerformerCreate} - icon="star" - header="Create Performer" - create - endpoint={endpoint} - />
: {performer.name}
- + {maybeRenderLinkButton()}
); diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index 46c1fdc6b..177b8d73c 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -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 = ; if (match) @@ -50,11 +58,8 @@ const getDurationStatus = (
); - if (!scene.duration && durations.length === 0) - return ; - 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; - setExcludedFields: (v: Record) => void; } -interface IPerformerReducerAction { - id: string; - data: PerformerOperation; -} - -const performerReducer = ( - state: Record, - action: IPerformerReducerAction -) => ({ ...state, [action.id]: action.data }); - const StashSearchResult: React.FC = ({ 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 = ({ } throw new Error("unexpected tagOperation"); - }, [stashScene, tagOperation, scene, setTags]); + }, [stashScene, scene, config]); - const [studio, setStudio] = useState(); - const [performers, dispatch] = useReducer(performerReducer, {}); - const [tagIDs, setTagIDs] = useState(getInitialTags()); - const [saveState, setSaveState] = useState(""); - 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>( {} ); + const [tagIDs, setTagIDs] = useState(getInitialTags()); - const intl = useIntl(); + // map of original performer to id + const [performerIDs, setPerformerIDs] = useState<(string | undefined)[]>( + getInitialPerformers() + ); + + const [studioID, setStudioID] = useState( + 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]); - async function handleSave() { - const updatedScene = await tagScene( - stashScene, - scene, - studio, - performers, - tagIDs, - getExcludedFields(), - endpoint - ); + useEffect(() => { + async function doResolveScene() { + try { + setLoading(true); + await resolveScene(stashScene.id, index, scene); + } finally { + setLoading(false); + } + } - if (updatedScene) setScene(updatedScene); + if (isActive && !loading && !scene.resolved) { + doResolveScene(); + } + }, [isActive, loading, stashScene, index, resolveScene, scene]); - queueFingerprintSubmission(stashScene.id, endpoint); - } - - 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 = ({ [name]: value, }); - const classname = cx("row mx-0 mt-2 search-result", { - "selected-result": isActive, - }); + async function handleSave() { + const excludedFieldList = Object.keys(excludedFields).filter( + (f) => excludedFields[f] + ); - const sceneTitle = scene.url ? ( - - - - ) : ( - - ); + function resolveField(field: string, stashField: T, remoteField: T) { + if (excludedFieldList.includes(field)) { + return stashField; + } - const saveEnabled = - Object.keys(performers ?? []).length === - scene.performers.filter((p) => p.gender !== "MALE" || showMales).length && - Object.keys(performers ?? []).every((id) => performers?.[id].type) && - saveState === ""; + return remoteField; + } - const endpointBase = endpoint.match(/https?:\/\/.*?\//)?.[0]; - const stashBoxURL = endpointBase - ? `${endpointBase}scenes/${scene.stash_id}` - : ""; + 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,175 +331,331 @@ const StashSearchResult: React.FC = ({ date: "date", url: "url", details: "details", + studio: "studio", + stash_ids: "stash_ids", }; + const maybeRenderCoverImage = () => { + if (scene.image) { + return ( +
+ setExcludedField(fields.cover_image, v)} + > + + +
+ ); + } + }; + + const renderTitle = () => { + if (!scene.title) { + return ( +

+ +

+ ); + } + + const sceneTitleEl = scene.url ? ( + + + + ) : ( + + ); + + return ( +

+ setExcludedField(fields.title, v)} + > + {sceneTitleEl} + +

+ ); + }; + + function renderStudioDate() { + const text = + scene.studio && scene.date + ? `${scene.studio.name} • ${scene.date}` + : `${scene.studio?.name ?? scene.date ?? ""}`; + + if (text) { + return
{text}
; + } + } + + const renderPerformerList = () => { + if (scene.performers?.length) { + return ( +
+ {intl.formatMessage( + { id: "countables.performers" }, + { count: scene?.performers?.length } + )} + : {scene?.performers?.map((p) => p.name).join(", ")} +
+ ); + } + }; + + const maybeRenderDateField = () => { + if (isActive && scene.date) { + return ( +
+ setExcludedField(fields.date, v)} + > + {scene.date} + +
+ ); + } + }; + + const maybeRenderURL = () => { + if (scene.url) { + return ( +
+ setExcludedField(fields.url, v)} + > + + {scene.url} + + +
+ ); + } + }; + + const maybeRenderDetails = () => { + if (scene.details) { + return ( +
+ setExcludedField(fields.details, v)} + > + + +
+ ); + } + }; + + const maybeRenderStashBoxID = () => { + if (scene.remote_site_id && stashBoxURL) { + return ( +
+ setExcludedField(fields.stash_ids, v)} + > + + {scene.remote_site_id} + + +
+ ); + } + }; + + const maybeRenderStudioField = () => { + if (scene.studio) { + return ( +
+ setStudioID(id)} + onCreate={() => showStudioModal(scene.studio!)} + endpoint={currentSource?.stashboxEndpoint} + onLink={async () => { + await linkStudio(scene.studio!, studioID!); + }} + /> +
+ ); + } + }; + + function setPerformerID(performerIndex: number, id: string | undefined) { + const newPerformerIDs = [...performerIDs]; + newPerformerIDs[performerIndex] = id; + setPerformerIDs(newPerformerIDs); + } + + const renderPerformerField = () => ( +
+
+ + {performers.map((performer, performerIndex) => ( + setPerformerID(performerIndex, id)} + onCreate={() => showPerformerModal(performer)} + onLink={async () => { + await linkPerformer(performer, performerIDs[performerIndex]!); + }} + endpoint={currentSource?.stashboxEndpoint} + key={`${performer.name ?? performer.remote_site_id ?? ""}`} + /> + ))} + +
+
+ ); + + const renderTagsField = () => ( +
+
+ + {FormUtils.renderLabel({ + title: `${intl.formatMessage({ id: "tags" })}:`, + })} + + { + setTagIDs(items.map((i) => i.id)); + }} + ids={tagIDs} + /> + + +
+ {scene.tags + ?.filter((t) => !t.stored_id) + .map((t) => ( + { + createNewTag(t); + }} + > + {t.name} + + + ))} +
+ ); + + if (loading) { + return ; + } + return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -
  • !isActive && setActive()} - > -
    -
    -
    - setExcludedField(fields.cover_image, v)} - > - - - - -
    + <> +
    +
    + {maybeRenderCoverImage()}
    -

    - setExcludedField(fields.title, v)} - > - {sceneTitle} - -

    + {renderTitle()} {!isActive && ( <> -
    - {scene?.studio?.name} • {scene?.date} -
    -
    - {intl.formatMessage( - { id: "countables.performers" }, - { count: scene?.performers?.length } - )} - : {scene?.performers?.map((p) => p.name).join(", ")} -
    + {renderStudioDate()} + {renderPerformerList()} )} - {isActive && scene.date && ( -
    - setExcludedField(fields.date, v)} - > - {scene.date} - -
    - )} + {maybeRenderDateField()} {getDurationStatus(scene, stashScene.file?.duration)} {getFingerprintStatus(scene, stashScene)}
    {isActive && (
    - {scene.url && ( -
    - setExcludedField(fields.url, v)} - > - - {scene.url} - - -
    - )} - {scene.details && ( -
    - setExcludedField(fields.details, v)} - > - - -
    - )} + {maybeRenderStashBoxID()} + {maybeRenderURL()} + {maybeRenderDetails()}
    )}
    {isActive && (
    - - {scene.performers - .filter((p) => p.gender !== "MALE" || showMales) - .map((performer) => ( - - setPerformer(data, performer.stash_id) - } - key={`${scene.stash_id}${performer.stash_id}`} - endpoint={endpoint} - /> - ))} -
    -
    - - {FormUtils.renderLabel({ - title: `${intl.formatMessage({ id: "tags" })}:`, - })} - - { - setTagIDs(items.map((i) => i.id)); - }} - ids={tagIDs} - /> - - -
    - {setTags && - scene.tags - .filter((t) => !t.id) - .map((t) => ( - { - createNewTag(t); - }} - > - {t.name} - - - ))} -
    + {maybeRenderStudioField()} + {renderPerformerField()} + {renderTagsField()} +
    - {error.message && ( - - - Error: - - {error.message} - - )} - {saveState && ( - - {saveState} - - )} - + + +
    )} -
  • + + ); +}; + +export interface ISceneSearchResults { + target: GQL.SlimSceneDataFragment; + scenes: GQL.ScrapedSceneDataFragment[]; +} + +export const SceneSearchResults: React.FC = ({ + target, + scenes, +}) => { + const [selectedResult, setSelectedResult] = useState(); + + 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 ( +
      + {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 +
    • setSelectedResult(i)} + className={getClassName(i)} + > + +
    • + ))} +
    ); }; diff --git a/ui/v2.5/src/components/Tagger/StudioModal.tsx b/ui/v2.5/src/components/Tagger/StudioModal.tsx new file mode 100644 index 000000000..60e440a1a --- /dev/null +++ b/ui/v2.5/src/components/Tagger/StudioModal.tsx @@ -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 = ({ + 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 && ( +
    +
    + + : + +
    + {truncate ? ( + + ) : ( + {text} + )} +
    + ); + + const base = currentSource?.stashboxEndpoint?.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? `${base}studios/${studio.remote_site_id}` : undefined; + + return ( + closeModal()} + cancel={{ onClick: () => closeModal(), variant: "secondary" }} + icon={icon} + header={header} + > +
    +
    + {renderField("name", studio.name)} + {renderField("url", studio.url)} + {link && ( +
    + + Stash-Box Source + + +
    + )} +
    +
    + + {/* TODO - add image */} + {/*
    + Logo: + + + +
    */} +
    + ); +}; + +export default StudioModal; diff --git a/ui/v2.5/src/components/Tagger/StudioResult.tsx b/ui/v2.5/src/components/Tagger/StudioResult.tsx index 69690e853..c7f4e3fad 100755 --- a/ui/v2.5/src/components/Tagger/StudioResult.tsx +++ b/ui/v2.5/src/components/Tagger/StudioResult.tsx @@ -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>; + studio: GQL.ScrapedStudio; + selectedID: string | undefined; + setSelectedID: (id: string | undefined) => void; + onCreate: () => void; + onLink?: () => Promise; + endpoint?: string; } -const StudioResult: React.FC = ({ studio, setStudio }) => { - const intl = useIntl(); - const [selectedStudio, setSelectedStudio] = useState(); - 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 = ({ + 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
    Loading studio
    ; - if (loadingStashID) return
    Loading studio
    ; - - if (stashIDData?.findStudios.studios.length) { + if (matchedStudio && matchedStashID) { return (
    - - :{studio?.name} + : + {studio.name}
    - v ? handleStudioSkip() : setSelectedSource("existing") + v ? handleSkip() : setSelectedID(matchedStudio.id) } >
    : - - {stashIDData.findStudios.studios[0].name} - + {matchedStudio.name}
    @@ -124,60 +77,48 @@ const StudioResult: React.FC = ({ studio, setStudio }) => { ); } + function maybeRenderLinkButton() { + if (endpoint && onLink) { + return ( + + + + ); + } + } + + const selectedSource = !selectedID ? "skip" : "existing"; + return (
    - showModal(false), variant: "secondary" }} - > -
    - - : - - {studio?.name} -
    -
    - - : - - {studio?.url ?? ""} -
    -
    - Logo: - - - -
    -
    -
    - :{studio?.name} + : + {studio.name}
    - + {maybeRenderLinkButton()}
    ); diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index 981fadc80..b6ec37257 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -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 = ({ scenes, queue }) => { - const { configuration: stashConfig } = React.useContext(ConfigurationContext); - const [{ data: config }, setConfig] = useLocalForage( - 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 ; + function handleSourceSelect(e: React.ChangeEvent) { + 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 + + + + +
    + + {!sources.length && } + {sources.map((i) => ( + + ))} + +
    +
    ); } - async function doBoxSearch(searchVal: string) { - return (await stashBoxSceneQuery(searchVal, selectedEndpointIndex)).data; + function renderConfigButton() { + return ( +
    + +
    + ); } - 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); - const getQueue = (endpoint: string) => { - if (!config) return []; - return config.fingerprintQueue[endpoint] ?? []; - }; + 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 fingerprintQueue = { - queueFingerprintSubmission, - getQueue, - submitFingerprints: handleFingerprintSubmission, - submittingFingerprints, - }; - - return ( - <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> -
    - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
    - - -
    - - - - - ) : ( -
    -

    - To use the scene tagger a stash-box instance needs to be - configured. -

    -
    - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) + return ( + { + await doSceneQuery(scene.id, v); } - > - Settings. - -
    -
    + : undefined + } + scrapeSceneFragment={ + currentSource?.supportSceneFragment + ? async () => { + await doSceneFragmentScrape(scene.id); + } + : undefined + } + > + {searchResult && searchResult.results?.length ? ( + + ) : undefined} + + ); + }); + } + + const toggleHideUnmatchedScenes = () => { + setHideUnmatched(!hideUnmatched); + }; + + function maybeRenderShowHideUnmatchedButton() { + if (Object.keys(searchResults).length) { + return ( + + ); + } + } + + function maybeRenderSubmitFingerprintsButton() { + if (pendingFingerprints.length) { + return ( + + + + + + ); + } + } + + function renderFragmentScrapeButton() { + if (!currentSource?.supportSceneFragment) { + return; + } + + if (loadingMulti) { + return ( + + ); + } + + return ( +
    + { + await doMultiSceneFragmentScrape(scenes.map((s) => s.id)); + }} + > + {intl.formatMessage({ id: "component_tagger.verb_scrape_all" })} + + {multiError && ( + <> +
    + {multiError} + )}
    - + ); + } + + return ( + +
    +
    +
    +
    {renderSourceSelector()}
    +
    + {maybeRenderShowHideUnmatchedButton()} + {maybeRenderSubmitFingerprintsButton()} + {renderFragmentScrapeButton()} + {renderConfigButton()} +
    +
    + +
    +
    {renderScenes()}
    +
    +
    ); }; diff --git a/ui/v2.5/src/components/Tagger/TaggerList.tsx b/ui/v2.5/src/components/Tagger/TaggerList.tsx deleted file mode 100644 index 5edba84b0..000000000 --- a/ui/v2.5/src/components/Tagger/TaggerList.tsx +++ /dev/null @@ -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 | undefined; - submittingFingerprints: boolean; -} - -interface ITaggerListProps { - scenes: GQL.SlimSceneDataFragment[]; - queue?: SceneQueue; - selectedEndpoint: { endpoint: string; index: number }; - config: ITaggerConfig; - queryScene: (searchVal: string) => Promise; - fingerprintQueue: IFingerprintQueue; -} - -// Caches fingerprint lookups between page renders -let fingerprintCache: Record = {}; - -function fingerprintSearchResults( - scenes: GQL.SlimSceneDataFragment[], - fingerprints: Record -) { - const ret: Record = {}; - - if (Object.keys(fingerprints).length === 0) { - return ret; - } - - scenes.forEach((s) => { - ret[s.id] = fingerprints[s.id]; - }); - - return ret; -} - -export const TaggerList: React.FC = ({ - 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(null); - - const [searchErrors, setSearchErrors] = useState< - Record - >({}); - const [taggedScenes, setTaggedScenes] = useState< - Record> - >({}); - const [loadingFingerprints, setLoadingFingerprints] = useState(false); - const [fingerprints, setFingerprints] = useState< - Record - >(fingerprintCache); - const [searchResults, setSearchResults] = useState< - Record - >(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) => { - 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: ( - - Created tag: {toCreate.name} - - ), - }); - } 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 ( - doSceneQuery(scene.id, queryString)} - tagScene={handleTaggedScene} - searchResult={searchResult} - createNewTag={createNewTag} - /> - ); - }); - - return ( - -
    - {/* TODO - sources select goes here */} - {fingerprintError} -
    - {(getFingerprintCount() > 0 || hideUnmatched) && ( - - )} -
    -
    - {queuedFingerprints.length > 0 && ( - - )} -
    - -
    -
    {renderScenes()}
    -
    - ); -}; diff --git a/ui/v2.5/src/components/Tagger/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/TaggerScene.tsx index 3f4019522..16eb719ab 100644 --- a/ui/v2.5/src/components/Tagger/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/TaggerScene.tsx @@ -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 = ({ scene }) => { const sorted = sortPerformers(scene.performers); return ( -
    +
    @@ -78,55 +72,29 @@ const TaggerSceneDetails: React.FC = ({ 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; - tagScene: (scene: Partial) => void; - endpoint: string; - queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; - createNewTag: (toCreate: GQL.ScrapedTag) => void; } -export const TaggerScene: React.FC = ({ +export const TaggerScene: React.FC> = ({ scene, url, - config, - searchResult, - hideUnmatched, loading, doSceneQuery, - taggedScene, - tagScene, - endpoint, - queueFingerprintSubmission, - createNewTag, + scrapeSceneFragment, + errorMessage, + children, }) => { - const [selectedResult, setSelectedResult] = useState(0); - const [excluded, setExcluded] = useState>({}); + const { config } = useContext(TaggerStateContext); + const [queryString, setQueryString] = useState(""); + const [queryLoading, setQueryLoading] = useState(false); - const queryString = useRef(""); - - 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,72 +103,56 @@ export const TaggerScene: React.FC = ({ 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 ( -
    -
    - -
    -
    - ); - } + async function query() { + if (!doSceneQuery) return; - if (!taggedScene && !hasStashIDs) { - return ( - - - - - - - ) => { - queryString.current = e.currentTarget.value; - }} - onKeyPress={(e: React.KeyboardEvent) => - e.key === "Enter" && - doSceneQuery(queryString.current || defaultQueryString) - } - /> - - - - - ); - } - - if (taggedScene) { - return ( -
    -
    - -
    -
    - - {taggedScene.title} - -
    -
    - ); + try { + setQueryLoading(true); + await doSceneQuery(queryString || defaultQueryString); + } finally { + setQueryLoading(false); } } - function renderSubContent() { + function renderQueryForm() { + if (!doSceneQuery) return; + + return ( + + + + + + + ) => { + setQueryString(e.currentTarget.value); + }} + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && query() + } + /> + + + + + + + ); + } + + 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 = ({ return link; }); - return <>{stashLinks}; - } - - if (searchError) { - return
    {searchError}
    ; - } - - if (emptyResults) { - return ( -
    - -
    - ); + return
    {stashLinks}
    ; } } - function renderSearchResult() { - if (searchResults.length > 0 && !taggedScene) { - return ( -
      - {sortScenesByDuration( - searchResults, - scene.file.duration ?? undefined - ).map( - (sceneResult, i) => - sceneResult && ( - 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)} - /> - ) - )} -
    - ); - } - } - - return hideUnmatched && emptyResults ? null : ( + return (
    @@ -285,19 +191,33 @@ export const TaggerScene: React.FC = ({
    - +
    -
    - {renderMainContent()} -
    {renderSubContent()}
    +
    +
    + {renderQueryForm()} + {scrapeSceneFragment ? ( +
    + { + await scrapeSceneFragment(scene); + }} + > + + +
    + ) : undefined} +
    + {errorMessage ? ( +
    {errorMessage}
    + ) : undefined} + {maybeRenderStashLinks()}
    - {renderSearchResult()} + {children}
    ); }; diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index 5b78060c8..3485e810a 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -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", diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx new file mode 100644 index 000000000..86fb17443 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -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; + setCurrentSource: (src?: ITaggerSource) => void; + doSceneQuery: (sceneID: string, searchStr: string) => Promise; + doSceneFragmentScrape: (sceneID: string) => Promise; + doMultiSceneFragmentScrape: (sceneIDs: string[]) => Promise; + stopMultiScrape: () => void; + createNewTag: (toCreate: GQL.ScrapedTag) => Promise; + createNewPerformer: ( + toCreate: GQL.PerformerCreateInput + ) => Promise; + linkPerformer: ( + performer: GQL.ScrapedPerformer, + performerID: string + ) => Promise; + createNewStudio: ( + toCreate: GQL.StudioCreateInput + ) => Promise; + linkStudio: (studio: GQL.ScrapedStudio, studioID: string) => Promise; + resolveScene: ( + sceneID: string, + index: number, + scene: IScrapedScene + ) => Promise; + submitFingerprints: () => Promise; + pendingFingerprints: string[]; + saveScene: ( + sceneCreateInput: GQL.SceneUpdateInput, + queueFingerprint: boolean + ) => Promise; +} + +const dummyFn = () => { + return Promise.resolve(); +}; +const dummyValFn = () => { + return Promise.resolve(undefined); +}; + +export const TaggerStateContext = React.createContext({ + 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( + LOCAL_FORAGE_KEY, + initialConfig + ); + + const [loading, setLoading] = useState(false); + const [loadingMulti, setLoadingMulti] = useState(false); + const [sources, setSources] = useState([]); + const [currentSource, setCurrentSource] = useState(); + const [multiError, setMultiError] = useState(); + const [searchResults, setSearchResults] = useState< + Record + >({}); + + 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: ( + + Created tag: {toCreate.name} + + ), + }); + + 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: ( + + Created performer: {toCreate.name} + + ), + }); + + 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: Added stash-id to performer, + }); + } + } 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: ( + + Created studio: {toCreate.name} + + ), + }); + + 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: Added stash-id to studio, + }); + } + } catch (e) { + Toast.error(e); + } + } + + return ( + { + setCurrentSource(src); + }, + doSceneQuery, + doSceneFragmentScrape, + doMultiSceneFragmentScrape, + stopMultiScrape, + createNewTag, + createNewPerformer, + linkPerformer, + createNewStudio, + linkStudio, + resolveScene, + saveScene, + submitFingerprints, + pendingFingerprints: getPendingFingerprints(), + }} + > + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 0fe50cb31..947ccee02 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -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 = ({ 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 = ({ }, }); } - setModalPerformer(undefined); }; const renderPerformers = () => @@ -351,7 +340,7 @@ const PerformerTaggerList: React.FC = ({ closeModal={() => setModalPerformer(undefined)} modalVisible={modalPerformer !== undefined} performer={modalPerformer} - handlePerformerCreate={handlePerformerUpdate} + onSave={handlePerformerUpdate} excludedPerformerFields={config.excludedPerformerFields} icon="tags" header="Update Performer" diff --git a/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx index cc74777d9..cd97d2dfa 100755 --- a/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx @@ -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 = ({ 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 = ({ closeModal={() => setModalPerformer(undefined)} modalVisible={modalPerformer !== undefined} performer={modalPerformer} - handlePerformerCreate={handleSave} + onSave={handleSave} icon="tags" header="Update Performer" excludedPerformerFields={excludedPerformerFields} diff --git a/ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx b/ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx new file mode 100644 index 000000000..ad6484064 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx @@ -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( + { + 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 ( + + {performerToCreate && ( + + )} + {studioToCreate && ( + + )} + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index ed38e7d74..7245ffc10 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -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 { diff --git a/ui/v2.5/src/components/Tagger/taggerService.ts b/ui/v2.5/src/components/Tagger/taggerService.ts deleted file mode 100644 index 3f41f8bde..000000000 --- a/ui/v2.5/src/components/Tagger/taggerService.ts +++ /dev/null @@ -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(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; -} diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 8ba60a5d5..d4cfe43da 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -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) => { diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 2a2e4abd4..97f9d94d3 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -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({ + 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({ + query: GQL.FindStudioDocument, + variables: { + id, + }, + }); export const useFindMovie = (id: string) => { const skip = id === "new"; return GQL.useFindMovieQuery({ variables: { id }, skip }); diff --git a/ui/v2.5/src/hooks/Toast.tsx b/ui/v2.5/src/hooks/Toast.tsx index b8e94da71..5489fa405 100644 --- a/ui/v2.5/src/hooks/Toast.tsx +++ b/ui/v2.5/src/hooks/Toast.tsx @@ -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: "Unknown error", + }); + return; + } + + console.error(message); toastFunc({ variant: "danger", header: "Error", - content: error.message ?? error.toString(), + content: message, }); + /* eslint-enable @typescript-eslint/no-explicit-any, no-console */ }, }; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 2d77a815b..0a53bda52 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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"