From 3d185006baebae95f4ef6250af1ac37e80577476 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 29 Sep 2021 19:38:17 +1000 Subject: [PATCH 01/12] Refactor Tagger view --- ui/v2.5/src/components/Scenes/SceneList.tsx | 6 +- .../src/components/Scenes/Scraper/Config.tsx | 218 ++++++ .../Scenes/Scraper/PerformerModal.tsx | 227 ++++++ .../Scenes/Scraper/SceneScraper.tsx | 237 ++++++ .../Scenes/Scraper/SceneScraperScene.tsx | 225 ++++++ .../Scraper/SceneScraperSceneEditor.tsx | 590 ++++++++++++++ .../Scraper/SceneScraperSearchResult.tsx | 146 ++++ .../components/Scenes/Scraper/StudioModal.tsx | 114 +++ .../components/Scenes/Scraper/constants.ts | 13 + .../src/components/Scenes/Scraper/context.tsx | 629 +++++++++++++++ .../src/components/Scenes/Scraper/modals.tsx | 123 +++ .../components/Shared/LoadingIndicator.tsx | 4 +- .../src/components/Shared/OperationButton.tsx | 43 ++ ui/v2.5/src/components/Shared/styles.scss | 5 +- ui/v2.5/src/components/Tagger/Config.tsx | 46 +- .../src/components/Tagger/IncludeButton.tsx | 20 +- .../src/components/Tagger/PerformerModal.tsx | 103 ++- .../src/components/Tagger/PerformerResult.tsx | 40 +- .../components/Tagger/StashSearchResult.tsx | 727 ++++++++++++------ ui/v2.5/src/components/Tagger/StudioModal.tsx | 114 +++ ui/v2.5/src/components/Tagger/Tagger.tsx | 350 +++++---- ui/v2.5/src/components/Tagger/TaggerList.tsx | 338 -------- ui/v2.5/src/components/Tagger/TaggerScene.tsx | 252 +++--- ui/v2.5/src/components/Tagger/constants.ts | 14 + .../Tagger/performers/PerformerTagger.tsx | 27 +- .../Tagger/performers/StashSearchResult.tsx | 24 +- .../components/Tagger/sceneTaggerModals.tsx | 130 ++++ ui/v2.5/src/components/Tagger/styles.scss | 28 +- .../src/components/Tagger/taggerContext.tsx | 629 +++++++++++++++ ui/v2.5/src/components/Tagger/utils.ts | 2 +- ui/v2.5/src/locales/en-GB.json | 4 + 31 files changed, 4398 insertions(+), 1030 deletions(-) create mode 100644 ui/v2.5/src/components/Scenes/Scraper/Config.tsx create mode 100644 ui/v2.5/src/components/Scenes/Scraper/PerformerModal.tsx create mode 100644 ui/v2.5/src/components/Scenes/Scraper/SceneScraper.tsx create mode 100644 ui/v2.5/src/components/Scenes/Scraper/SceneScraperScene.tsx create mode 100644 ui/v2.5/src/components/Scenes/Scraper/SceneScraperSceneEditor.tsx create mode 100644 ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx create mode 100644 ui/v2.5/src/components/Scenes/Scraper/StudioModal.tsx create mode 100644 ui/v2.5/src/components/Scenes/Scraper/constants.ts create mode 100644 ui/v2.5/src/components/Scenes/Scraper/context.tsx create mode 100644 ui/v2.5/src/components/Scenes/Scraper/modals.tsx create mode 100644 ui/v2.5/src/components/Shared/OperationButton.tsx create mode 100644 ui/v2.5/src/components/Tagger/StudioModal.tsx delete mode 100644 ui/v2.5/src/components/Tagger/TaggerList.tsx create mode 100644 ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx create mode 100644 ui/v2.5/src/components/Tagger/taggerContext.tsx diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 8f64035ecef..043e1244265 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -13,7 +13,7 @@ import { useScenesList } from "src/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook"; -import Tagger from "src/components/Tagger"; +// import Tagger from "src/components/Tagger"; import { SceneQueue } from "src/models/sceneQueue"; import { WallPanel } from "../Wall/WallPanel"; import { SceneListTable } from "./SceneListTable"; @@ -22,6 +22,8 @@ import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { SceneGenerateDialog } from "./SceneGenerateDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { SceneCardsGrid } from "./SceneCardsGrid"; +import Tagger from "../Tagger"; +import { TaggerContext } from "../Tagger/taggerContext"; interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -253,5 +255,5 @@ export const SceneList: React.FC = ({ ); } - return listData.template; + return {listData.template}; }; diff --git a/ui/v2.5/src/components/Scenes/Scraper/Config.tsx b/ui/v2.5/src/components/Scenes/Scraper/Config.tsx new file mode 100644 index 00000000000..5f2da9a293e --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/Config.tsx @@ -0,0 +1,218 @@ +import React, { useRef, useContext } from "react"; +import { + Badge, + Button, + Card, + Collapse, + Form, + InputGroup, +} from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Icon } from "src/components/Shared"; +import { ParseMode, TagOperation } from "src/components/Tagger/constants"; +import { SceneScraperStateContext } from "./context"; + +interface IConfigProps { + show: boolean; +} + +const Config: React.FC = ({ show }) => { + const { config, setConfig } = useContext(SceneScraperStateContext); + const intl = useIntl(); + const blacklistRef = useRef(null); + + const removeBlacklist = (index: number) => { + setConfig({ + ...config, + blacklist: [ + ...config.blacklist.slice(0, index), + ...config.blacklist.slice(index + 1), + ], + }); + }; + + const handleBlacklistAddition = () => { + if (!blacklistRef.current) return; + + const input = blacklistRef.current.value; + if (input.length === 0) return; + + setConfig({ + ...config, + blacklist: [...config.blacklist, input], + }); + blacklistRef.current.value = ""; + }; + + return ( + + +
+

+ +

+
+
+ + + } + checked={config.showMales} + onChange={(e: React.ChangeEvent) => + setConfig({ ...config, showMales: e.currentTarget.checked }) + } + /> + + + + + + + } + checked={config.setCoverImage} + onChange={(e: React.ChangeEvent) => + setConfig({ + ...config, + setCoverImage: e.currentTarget.checked, + }) + } + /> + + + + + +
+ + } + className="mr-4" + checked={config.setTags} + onChange={(e: React.ChangeEvent) => + setConfig({ ...config, setTags: e.currentTarget.checked }) + } + /> + ) => + setConfig({ + ...config, + tagOperation: e.currentTarget.value as TagOperation, + }) + } + disabled={!config.setTags} + > + + + +
+ + + +
+ + +
+ + + : + + ) => + setConfig({ + ...config, + mode: e.currentTarget.value as ParseMode, + }) + } + > + + + + + + +
+ + {intl.formatMessage({ + id: `component_tagger.config.query_mode_${config.mode}_desc`, + defaultMessage: "Unknown query mode", + })} + +
+
+
+
+ +
+ + + + + + +
+ {intl.formatMessage( + { id: "component_tagger.config.blacklist_desc" }, + { chars_require_escape: [\^$.|?*+() } + )} +
+ {config.blacklist.map((item, index) => ( + + {item.toString()} + + + ))} +
+
+
+
+ ); +}; + +export default Config; diff --git a/ui/v2.5/src/components/Scenes/Scraper/PerformerModal.tsx b/ui/v2.5/src/components/Scenes/Scraper/PerformerModal.tsx new file mode 100644 index 00000000000..96f7c4247eb --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/PerformerModal.tsx @@ -0,0 +1,227 @@ +import React, { useState, useContext } from "react"; +import { Button } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import cx from "classnames"; +import { IconName } from "@fortawesome/fontawesome-svg-core"; + +import { + LoadingIndicator, + Icon, + Modal, + TruncatedText, +} from "src/components/Shared"; +import * as GQL from "src/core/generated-graphql"; +import { genderToString, stringToGender } from "src/utils/gender"; +import { SceneScraperStateContext } from "./context"; + +interface IPerformerModalProps { + performer: GQL.ScrapedScenePerformerDataFragment; + modalVisible: boolean; + closeModal: () => void; + handlePerformerCreate: (input: GQL.PerformerCreateInput) => void; + header: string; + icon: IconName; +} + +const PerformerModal: React.FC = ({ + modalVisible, + performer, + handlePerformerCreate, + closeModal, + header, + icon, +}) => { + const { currentSource } = useContext(SceneScraperStateContext); + const intl = useIntl(); + const [imageIndex, setImageIndex] = useState(0); + const [imageState, setImageState] = useState< + "loading" | "error" | "loaded" | "empty" + >("empty"); + const [loadDict, setLoadDict] = useState>({}); + + const images = performer.images ?? []; + + const changeImage = (index: number) => { + setImageIndex(index); + if (!loadDict[index]) setImageState("loading"); + }; + const setPrev = () => + changeImage(imageIndex === 0 ? images.length - 1 : imageIndex - 1); + const setNext = () => + changeImage(imageIndex === images.length - 1 ? 0 : imageIndex + 1); + + const handleLoad = (index: number) => { + setLoadDict({ + ...loadDict, + [index]: true, + }); + setImageState("loaded"); + }; + const handleError = () => setImageState("error"); + + const renderField = ( + id: string, + text: string | null | undefined, + truncate: boolean = true + ) => + text && ( +
+
+ + : + +
+ {truncate ? ( + + ) : ( + {text} + )} +
+ ); + + function onSave() { + 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 && currentSource?.stashboxEndpoint) { + performerData.stash_ids = [ + { + endpoint: currentSource.stashboxEndpoint, + stash_id: remoteSiteID, + }, + ]; + } + + handlePerformerCreate(performerData); + } + + const base = currentSource?.stashboxEndpoint?.match(/https?:\/\/.*?\//)?.[0]; + const link = base + ? `${base}performers/${performer.remote_site_id}` + : undefined; + + return ( + closeModal(), variant: "secondary" }} + onHide={() => closeModal()} + dialogClassName="performer-create-modal" + icon={icon} + header={header} + > +
+
+ {renderField("name", performer.name)} + {renderField( + "gender", + performer.gender ? genderToString(performer.gender) : "" + )} + {renderField("birthdate", performer.birthdate)} + {renderField("death_date", performer.death_date)} + {renderField("ethnicity", performer.ethnicity)} + {renderField("country", performer.country)} + {renderField("hair_color", performer.hair_color)} + {renderField("eye_color", performer.eye_color)} + {renderField("height", performer.height)} + {renderField("weight", performer.weight)} + {renderField("measurements", performer.measurements)} + {performer?.gender !== GQL.GenderEnum.Male && + renderField("fake_tits", performer.fake_tits)} + {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 && ( +
+ + Stash-Box Source + + +
+ )} +
+ {images.length > 0 && ( +
+
+ handleLoad(imageIndex)} + onError={handleError} + /> + {imageState === "loading" && ( + + )} + {imageState === "error" && ( +
+ Error loading image. +
+ )} +
+
+ +
+ Select performer image +
+ {imageIndex + 1} of {images.length} +
+ +
+
+ )} +
+
+ ); +}; + +export default PerformerModal; diff --git a/ui/v2.5/src/components/Scenes/Scraper/SceneScraper.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraper.tsx new file mode 100644 index 00000000000..d948ccb1cee --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/SceneScraper.tsx @@ -0,0 +1,237 @@ +import React, { useContext, useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { SceneQueue } from "src/models/sceneQueue"; +import { Button, Card, 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 { SceneScraperScene } from "./SceneScraperScene"; +import { SceneScraperStateContext } from "./context"; +import { SceneSearchResults } from "./SceneScraperSearchResult"; +import { SceneScraperModals } from "./modals"; +import Config from "./Config"; + +interface IScraperProps { + scenes: GQL.SlimSceneDataFragment[]; + queue?: SceneQueue; +} + +export const SceneScraper: React.FC = ({ scenes, queue }) => { + const { + sources, + setCurrentSource, + currentSource, + doSceneQuery, + doSceneFragmentScrape, + doMultiSceneFragmentScrape, + stopMultiScrape, + searchResults, + loading, + loadingMulti, + multiError, + submitFingerprints, + pendingFingerprints, + } = useContext(SceneScraperStateContext); + + const [showConfig, setShowConfig] = useState(false); + const [hideUnmatched, setHideUnmatched] = useState(false); + + const intl = useIntl(); + + function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) { + return queue + ? queue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; + } + + function handleSourceSelect(e: React.ChangeEvent) { + setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value)); + } + + function renderSourceSelector() { + return ( + + + + +
+ + {!sources.length && } + {sources.map((i) => ( + + ))} + +
+
+ ); + } + + function renderConfigButton() { + return ( +
+ +
+ ); + } + + function renderScenes() { + const filteredScenes = !hideUnmatched + ? scenes + : scenes.filter((s) => searchResults[s.id]?.results?.length); + + return filteredScenes.map((scene, index) => { + const sceneLink = generateSceneLink(scene, index); + let errorMessage: string | undefined; + const searchResult = searchResults[scene.id]; + if (searchResult?.error) { + errorMessage = searchResult.error; + } else if (searchResult && searchResult.results?.length === 0) { + errorMessage = intl.formatMessage({ + id: "component_tagger.results.match_failed_no_result", + }); + } + + return ( + { + await doSceneQuery(scene.id, v); + } + : undefined + } + scrapeSceneFragment={ + currentSource?.supportFragment + ? 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?.supportFragment) { + 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/Scenes/Scraper/SceneScraperScene.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperScene.tsx new file mode 100644 index 00000000000..784b2016606 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperScene.tsx @@ -0,0 +1,225 @@ +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 { parsePath, prepareQueryString } from "src/components/Tagger/utils"; +import { OperationButton } from "src/components/Shared/OperationButton"; +import { ScenePreview } from "../SceneCard"; +import { SceneScraperStateContext } from "./context"; + +interface IScraperSceneDetails { + scene: GQL.SlimSceneDataFragment; +} + +const ScraperSceneDetails: React.FC = ({ scene }) => { + const [open, setOpen] = useState(false); + const sorted = sortPerformers(scene.performers); + + return ( +
+ +
+
+

{scene.title}

+
+ {scene.studio?.name} + {scene.studio?.name && scene.date && ` • `} + {scene.date} +
+ +
+
+
+ {sorted.map((performer) => ( +
+ + {performer.name + + +
+ ))} +
+
+ {scene.tags.map((tag) => ( + + ))} +
+
+
+
+ +
+ ); +}; + +interface ISceneScraperSceneProps { + scene: GQL.SlimSceneDataFragment; + url: string; + errorMessage?: string; + doSceneQuery?: (queryString: string) => void; + scrapeSceneFragment?: (scene: GQL.SlimSceneDataFragment) => void; + loading?: boolean; +} + +export const SceneScraperScene: React.FC< + PropsWithChildren +> = ({ + scene, + url, + loading, + doSceneQuery, + scrapeSceneFragment, + errorMessage, + children, +}) => { + const { config } = useContext(SceneScraperStateContext); + const [queryString, setQueryString] = useState(""); + const [queryLoading, setQueryLoading] = useState(false); + + const { paths, file } = parsePath(scene.path); + const defaultQueryString = prepareQueryString( + scene, + paths, + file, + config.mode, + config.blacklist + ); + + const width = scene.file.width ? scene.file.width : 0; + const height = scene.file.height ? scene.file.height : 0; + const isPortrait = height > width; + + async function query() { + if (!doSceneQuery) return; + + try { + setQueryLoading(true); + await doSceneQuery(queryString || defaultQueryString); + } finally { + setQueryLoading(false); + } + } + + 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]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( +
{stashID.stash_id}
+ ); + + return link; + }); + return
{stashLinks}
; + } + } + + return ( +
+
+
+
+ + + +
+ + + +
+
+
+ {renderQueryForm()} + {scrapeSceneFragment ? ( +
+ { + await scrapeSceneFragment(scene); + }} + > + + +
+ ) : undefined} +
+ {errorMessage ? ( +
{errorMessage}
+ ) : undefined} + {maybeRenderStashLinks()} +
+ +
+ {children} +
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSceneEditor.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSceneEditor.tsx new file mode 100644 index 00000000000..b6432ea2ea1 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSceneEditor.tsx @@ -0,0 +1,590 @@ +import React, { useState, useCallback, useEffect, useMemo } from "react"; +import { Badge, Button, Col, Form, Row } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; + +import * as GQL from "src/core/generated-graphql"; +import { + Icon, + LoadingIndicator, + PerformerSelect, + StudioSelect, + SuccessIcon, + TagSelect, + TruncatedText, +} from "src/components/Shared"; +import { FormUtils } from "src/utils"; +import { uniq } from "lodash"; +import { OptionalField } from "src/components/Tagger/IncludeButton"; +import { stringToGender } from "src/utils/gender"; +import { blobToBase64 } from "base64-blob"; +import { OperationButton } from "src/components/Shared/OperationButton"; +import { IScrapedScene, SceneScraperStateContext } from "./context"; +import { SceneScraperDialogsState } from "./modals"; + +const getDurationStatus = ( + scene: IScrapedScene, + stashDuration: number | undefined | null +) => { + if (!stashDuration) return ""; + + 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; + if (matchCount > 0) + match = ( + + ); + else if (Math.abs(sceneDuration - stashDuration) < 5) + match = ; + + if (match) + return ( +
+ + {match} +
+ ); + + const minDiff = Math.min( + Math.abs(sceneDuration - stashDuration), + ...durations + ); + return ( + + ); +}; + +const getFingerprintStatus = ( + scene: IScrapedScene, + stashScene: GQL.SlimSceneDataFragment +) => { + const checksumMatch = scene.fingerprints?.some( + (f) => f.hash === stashScene.checksum || f.hash === stashScene.oshash + ); + const phashMatch = scene.fingerprints?.some( + (f) => f.hash === stashScene.phash + ); + if (checksumMatch || phashMatch) + return ( +
+ + + ), + }} + /> +
+ ); +}; + +interface IStashSearchResultProps { + scene: IScrapedScene; + stashScene: GQL.SlimSceneDataFragment; + index: number; + isActive: boolean; +} + +const SceneScraperSceneEditor: React.FC = ({ + scene, + stashScene, + index, + isActive, +}) => { + const intl = useIntl(); + + const { + config, + createNewTag, + createNewPerformer, + createNewStudio, + resolveScene, + currentSource, + saveScene, + } = React.useContext(SceneScraperStateContext); + + 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( + SceneScraperDialogsState + ); + + const getInitialTags = useCallback(() => { + const stashSceneTags = stashScene.tags.map((t) => t.id); + if (!config.setTags) { + return stashSceneTags; + } + + const { tagOperation } = config; + + const newTags = + scene.tags?.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? []; + + if (tagOperation === "overwrite") { + return newTags; + } + if (tagOperation === "merge") { + return uniq(stashSceneTags.concat(newTags)); + } + + throw new Error("unexpected tagOperation"); + }, [stashScene, scene, config]); + + const getInitialPerformers = useCallback(() => { + // default to override existing + return performers.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? []; + }, [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 [performerIDs, setPerformerIDs] = useState( + getInitialPerformers() + ); + const [studioID, setStudioID] = useState( + getInitialStudio() + ); + + useEffect(() => { + setTagIDs(getInitialTags()); + }, [getInitialTags]); + + useEffect(() => { + setPerformerIDs(getInitialPerformers()); + }, [getInitialPerformers]); + + useEffect(() => { + setStudioID(getInitialStudio()); + }, [getInitialStudio]); + + useEffect(() => { + async function doResolveScene() { + try { + setLoading(true); + await resolveScene(stashScene.id, index, scene); + } finally { + setLoading(false); + } + } + + if (isActive && !loading && !scene.resolved) { + doResolveScene(); + } + }, [isActive, loading, stashScene, index, resolveScene, scene]); + + // function getExcludedFields() { + // return Object.keys(excludedFields).filter((f) => excludedFields[f]); + // } + + const setExcludedField = (name: string, value: boolean) => + setExcludedFields({ + ...excludedFields, + [name]: value, + }); + + async function handleSave() { + const excludedFieldList = Object.keys(excludedFields).filter( + (f) => excludedFields[f] + ); + + function resolveField(field: string, stashField: T, remoteField: T) { + if (excludedFieldList.includes(field)) { + return stashField; + } + + return remoteField; + } + + let imgData; + if (!excludedFields.cover_image && config.setCoverImage) { + const imgurl = scene.image; + if (imgurl) { + const img = await fetch(imgurl, { + mode: "cors", + cache: "no-store", + }); + if (img.status === 200) { + const blob = await img.blob(); + // Sanity check on image size since bad images will fail + if (blob.size > 10000) imgData = await blobToBase64(blob); + } + } + } + + const 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: + performerIDs.length === 0 + ? stashScene.performers.map((p) => p.id) + : performerIDs, + 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 ?? [], + }; + + if (currentSource?.stashboxEndpoint && scene.remote_site_id) { + sceneCreateInput.stash_ids = [ + ...(stashScene?.stash_ids ?? []), + { + endpoint: currentSource.stashboxEndpoint, + stash_id: scene.remote_site_id, + }, + ]; + } + + await saveScene(sceneCreateInput); + } + + 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); + } + + const sceneTitle = scene.url ? ( + + + + ) : ( + + ); + + // constants to get around dot-notation eslint rule + const fields = { + cover_image: "cover_image", + title: "title", + date: "date", + url: "url", + details: "details", + studio: "studio", + }; + + const maybeRenderCoverImage = () => { + if (scene.image) { + return ( +
+ setExcludedField(fields.cover_image, v)} + > + + + + +
+ ); + } + }; + + const renderTitle = () => ( +

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

+ ); + + 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 renderStudioField = () => ( +
+
+ + {FormUtils.renderLabel({ + title: `${intl.formatMessage({ id: "studio" })}:`, + })} + + { + setStudioID(items[0]?.id); + }} + ids={studioID ? [studioID] : []} + /> + + +
+ {scene.studio && !scene.studio.stored_id && ( + { + showStudioModal(scene.studio!); + }} + > + {scene.studio.name} + + + )} +
+ ); + + const renderPerformerField = () => ( +
+
+ + {FormUtils.renderLabel({ + title: `${intl.formatMessage({ id: "performers" })}:`, + })} + + { + setPerformerIDs(items.map((i) => i.id)); + }} + ids={performerIDs} + /> + + +
+ {performers + ?.filter((p) => !p.stored_id) + .map((p) => ( + { + showPerformerModal(p); + }} + > + {p.name} + + + ))} +
+ ); + + 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 ( + <> +
+
+ {maybeRenderCoverImage()} +
+ {renderTitle()} + + {!isActive && ( + <> + {renderStudioDate()} + {renderPerformerList()} + + )} + + {maybeRenderDateField()} + {getDurationStatus(scene, stashScene.file?.duration)} + {getFingerprintStatus(scene, stashScene)} +
+
+ {isActive && ( +
+ {maybeRenderURL()} + {maybeRenderDetails()} +
+ )} +
+ {isActive && ( +
+ {renderStudioField()} + {renderPerformerField()} + {renderTagsField()} + +
+ + + +
+
+ )} + + ); +}; + +export default SceneScraperSceneEditor; diff --git a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx new file mode 100644 index 00000000000..0779a441934 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx @@ -0,0 +1,146 @@ +import React, { useState, useEffect } from "react"; +import { Badge, Col, Row } from "react-bootstrap"; +import cx from "classnames"; + +import * as GQL from "src/core/generated-graphql"; +import { TruncatedText } from "src/components/Shared"; +import SceneScraperSceneEditor from "./SceneScraperSceneEditor"; + +interface ISceneSearchResultDetailsProps { + scene: GQL.ScrapedSceneDataFragment; +} + +const SceneSearchResultDetails: React.FC = ({ + scene, +}) => { + function renderPerformers() { + if (scene.performers) { + return ( + + + {scene.performers?.map((performer) => ( + + {performer.name} + + ))} + + + ); + } + } + + function renderTags() { + if (scene.tags) { + return ( + + + {scene.tags?.map((tag) => ( + + {tag.name} + + ))} + + + ); + } + } + + function renderImage() { + if (scene.image) { + return ( +
+ +
+ ); + } + } + + return ( +
+ + {renderImage()} +
+

{scene.title}

+
+ {scene.studio?.name} + {scene.studio?.name && scene.date && ` • `} + {scene.date} +
+
+
+ + + + + + {renderPerformers()} + {renderTags()} +
+ ); +}; + +interface ISceneSearchResult { + scene: GQL.ScrapedSceneDataFragment; +} + +// TODO - decide if we want to keep this +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const SceneSearchResult: React.FC = ({ scene }) => { + return ( +
+
+ +
+
+ ); +}; + +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/Scenes/Scraper/StudioModal.tsx b/ui/v2.5/src/components/Scenes/Scraper/StudioModal.tsx new file mode 100644 index 00000000000..379b87587f7 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/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 { SceneScraperStateContext } 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(SceneScraperStateContext); + 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/Scenes/Scraper/constants.ts b/ui/v2.5/src/components/Scenes/Scraper/constants.ts new file mode 100644 index 00000000000..73a5ac059e4 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/constants.ts @@ -0,0 +1,13 @@ +import { ScraperSourceInput } from "src/core/generated-graphql"; + +export const STASH_BOX_PREFIX = "stashbox:"; +export const SCRAPER_PREFIX = "scraper:"; + +export interface IScraperSource { + id: string; + stashboxEndpoint?: string; + sourceInput: ScraperSourceInput; + displayName: string; + supportQuery?: boolean; + supportFragment?: boolean; +} diff --git a/ui/v2.5/src/components/Scenes/Scraper/context.tsx b/ui/v2.5/src/components/Scenes/Scraper/context.tsx new file mode 100644 index 00000000000..2b9d9f89403 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/context.tsx @@ -0,0 +1,629 @@ +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 { + queryScrapeScene, + queryScrapeSceneQuery, + queryScrapeSceneQueryFragment, + stashBoxSceneBatchQuery, + useConfiguration, + useListSceneScrapers, + usePerformerCreate, + useSceneUpdate, + useStudioCreate, + useTagCreate, +} from "src/core/StashService"; +import { useLocalForage, useToast } from "src/hooks"; +import { IScraperSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants"; + +export interface ISceneScraperContextState { + config: ITaggerConfig; + setConfig: (c: ITaggerConfig) => void; + loading: boolean; + loadingMulti?: boolean; + multiError?: string; + sources: IScraperSource[]; + currentSource?: IScraperSource; + searchResults: Record; + setCurrentSource: (src?: IScraperSource) => 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; + createNewStudio: ( + toCreate: GQL.StudioCreateInput + ) => Promise; + resolveScene: ( + sceneID: string, + index: number, + scene: IScrapedScene + ) => Promise; + submitFingerprints: () => Promise; + pendingFingerprints: string[]; + saveScene: (sceneCreateInput: GQL.SceneUpdateInput) => Promise; +} + +const dummyFn = () => { + return Promise.resolve(); +}; +const dummyValFn = () => { + return Promise.resolve(undefined); +}; + +export const SceneScraperStateContext = React.createContext( + { + config: initialConfig, + setConfig: () => {}, + loading: false, + sources: [], + searchResults: {}, + setCurrentSource: () => {}, + doSceneQuery: dummyFn, + doSceneFragmentScrape: dummyFn, + doMultiSceneFragmentScrape: dummyFn, + stopMultiScrape: () => {}, + createNewTag: dummyValFn, + createNewPerformer: dummyValFn, + createNewStudio: dummyValFn, + resolveScene: dummyFn, + submitFingerprints: dummyFn, + pendingFingerprints: [], + saveScene: dummyFn, + } +); + +export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean }; + +export interface ISceneQueryResult { + results?: IScrapedScene[]; + error?: string; +} + +export const SceneScraperContext: 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 stashConfig = useConfiguration(); + const Scrapers = useListSceneScrapers(); + + const Toast = useToast(); + const [createTag] = useTagCreate(); + const [createPerformer] = usePerformerCreate(); + const [createStudio] = useStudioCreate(); + const [updateScene] = useSceneUpdate(); + + useEffect(() => { + if (!stashConfig.data || !Scrapers.data) { + return; + } + + const { stashBoxes } = stashConfig.data.configuration.general; + const scrapers = Scrapers.data.listSceneScrapers; + + const stashboxSources: IScraperSource[] = stashBoxes.map((s, i) => ({ + id: `${STASH_BOX_PREFIX}${i}`, + stashboxEndpoint: s.endpoint, + sourceInput: { + stash_box_index: i, + }, + displayName: `stash-box: ${s.name}`, + supportFragment: true, + supportQuery: true, + })); + + // filter scraper sources such that only those that can query scrape or + // scrape via fragment are added + const scraperSources: IScraperSource[] = 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, + supportQuery: s.scene?.supported_scrapes.includes(GQL.ScrapeType.Name), + supportFragment: s.scene?.supported_scrapes.includes( + GQL.ScrapeType.Fragment + ), + })); + + setSources(stashboxSources.concat(scraperSources)); + }, [Scrapers.data, stashConfig.data]); + + 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; + // 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((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); + } 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()); + } + } 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) { + try { + await updateScene({ + variables: { + input: sceneCreateInput, + }, + }); + + 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 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); + } + } + + return ( + { + setCurrentSource(src); + }, + doSceneQuery, + doSceneFragmentScrape, + doMultiSceneFragmentScrape, + stopMultiScrape, + createNewTag, + createNewPerformer, + createNewStudio, + resolveScene, + saveScene, + submitFingerprints, + pendingFingerprints: getPendingFingerprints(), + }} + > + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Scenes/Scraper/modals.tsx b/ui/v2.5/src/components/Scenes/Scraper/modals.tsx new file mode 100644 index 00000000000..0e6dee5e9b8 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/Scraper/modals.tsx @@ -0,0 +1,123 @@ +import React, { useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import PerformerModal from "./PerformerModal"; +import StudioModal from "./StudioModal"; + +type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void; +type StudioModalCallback = (toCreate?: GQL.StudioCreateInput) => void; + +export interface ISceneScraperDialogsContextState { + createPerformerModal: ( + performer: GQL.ScrapedPerformerDataFragment, + callback: (toCreate?: GQL.PerformerCreateInput) => void + ) => void; + createStudioModal: ( + studio: GQL.ScrapedSceneStudioDataFragment, + callback: (toCreate?: GQL.StudioCreateInput) => void + ) => void; +} + +export const SceneScraperDialogsState = React.createContext( + { + createPerformerModal: () => {}, + createStudioModal: () => {}, + } +); + +export const SceneScraperModals: React.FC = ({ children }) => { + 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); + } + + return ( + + {performerToCreate && ( + + )} + {studioToCreate && ( + + )} + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx index d87defbcf18..f52498f0b22 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 00000000000..c3c60666c8a --- /dev/null +++ b/ui/v2.5/src/components/Shared/OperationButton.tsx @@ -0,0 +1,43 @@ +import React, { useState } from "react"; +import { Button, ButtonProps } from "react-bootstrap"; +import { LoadingIndicator } from "src/components/Shared"; + +interface IOperationButton extends ButtonProps { + operation?: () => Promise; + loading?: boolean; + setLoading?: (v: boolean) => void; +} + +export const OperationButton: React.FC = (props) => { + const [internalLoading, setInternalLoading] = useState(false); + + const { + operation, + loading: externalLoading, + setLoading: setExternalLoading, + ...withoutExtras + } = props; + + const setLoading = setExternalLoading || setInternalLoading; + const loading = + externalLoading !== undefined ? externalLoading : internalLoading; + + async function handleClick() { + if (operation) { + setLoading(true); + await operation(); + setLoading(false); + } + } + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index bff6a5907b1..2c16e3ff7c3 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 6faa452edc3..9d0b53b8a39 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 "./taggerContext"; 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 4292dfa2306..a4d9d3d11a3 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,16 @@ 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 913cb94d910..f16f11e5804 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 0f0726842f4..12e23342ffa 100755 --- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -6,7 +6,7 @@ import cx from "classnames"; import { 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 { IStashBoxPerformer } from "./utils"; import PerformerModal from "./PerformerModal"; import { OptionalField } from "./IncludeButton"; @@ -85,25 +85,25 @@ const PerformerResult: React.FC = ({ } }; - const handlePerformerCreate = ( - imageIndex: number, - excludedFields: string[] - ) => { - const selectedImage = performer.images[imageIndex]; - const images = selectedImage ? [selectedImage] : []; + // 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); - }; + // setSelectedSource("create"); + // setPerformer({ + // type: "create", + // data: { + // ...filterPerformer(performer, excludedFields), + // name: performer.name, + // stash_id: performer.stash_id, + // images, + // }, + // }); + // showModal(false); + // }; const handlePerformerSkip = () => { setSelectedSource("skip"); @@ -147,7 +147,7 @@ const PerformerResult: React.FC = ({ closeModal={() => showModal(false)} modalVisible={modalVisible} performer={performer} - handlePerformerCreate={handlePerformerCreate} + onSave={() => {}} icon="star" header="Create Performer" create diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index 46c1fdc6bc5..24801fa7b18 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"; @@ -7,28 +7,36 @@ import * as GQL from "src/core/generated-graphql"; import { Icon, LoadingIndicator, + PerformerSelect, + StudioSelect, SuccessIcon, TagSelect, TruncatedText, } 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 "./taggerContext"; +import { OperationButton } from "../Shared/OperationButton"; +import { SceneTaggerModalsState } from "./sceneTaggerModals"; 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,58 @@ 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, + createNewStudio, + 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 +159,55 @@ 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(() => { + // default to override existing + return performers.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? []; + }, [performers]); + + const getInitialStudio = useCallback(() => { + return scene.studio?.stored_id ?? stashScene.studio?.id; + }, [stashScene, scene]); + + const [loading, setLoading] = useState(false); + const [excludedFields, setExcludedFields] = useState>( {} ); - - const intl = useIntl(); + const [tagIDs, setTagIDs] = useState(getInitialTags()); + const [performerIDs, setPerformerIDs] = useState( + getInitialPerformers() + ); + const [studioID, setStudioID] = useState( + getInitialStudio() + ); useEffect(() => { setTagIDs(getInitialTags()); - }, [setTags, tagOperation, getInitialTags]); - - const tagScene = useTagScene( - { - tagOperation, - setCoverImage, - setTags, - }, - setSaveState, - setError - ); - - function getExcludedFields() { - return Object.keys(excludedFields).filter((f) => excludedFields[f]); - } + }, [getInitialTags]); - async function handleSave() { - const updatedScene = await tagScene( - stashScene, - scene, - studio, - performers, - tagIDs, - getExcludedFields(), - endpoint - ); + useEffect(() => { + setPerformerIDs(getInitialPerformers()); + }, [getInitialPerformers]); - if (updatedScene) setScene(updatedScene); + useEffect(() => { + setStudioID(getInitialStudio()); + }, [getInitialStudio]); - queueFingerprintSubmission(stashScene.id, endpoint); - } + useEffect(() => { + async function doResolveScene() { + try { + setLoading(true); + await resolveScene(stashScene.id, index, scene); + } finally { + setLoading(false); + } + } - const setPerformer = ( - performerData: PerformerOperation, - performerID: string - ) => dispatch({ id: performerID, data: performerData }); + if (isActive && !loading && !scene.resolved) { + doResolveScene(); + } + }, [isActive, loading, stashScene, index, resolveScene, scene]); const setExcludedField = (name: string, value: boolean) => setExcludedFields({ @@ -208,9 +215,85 @@ 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] + ); + + function resolveField(field: string, stashField: T, remoteField: T) { + if (excludedFieldList.includes(field)) { + return stashField; + } + + return remoteField; + } + + let imgData; + if (!excludedFields.cover_image && config.setCoverImage) { + const imgurl = scene.image; + if (imgurl) { + const img = await fetch(imgurl, { + mode: "cors", + cache: "no-store", + }); + if (img.status === 200) { + const blob = await img.blob(); + // Sanity check on image size since bad images will fail + if (blob.size > 10000) imgData = await blobToBase64(blob); + } + } + } + + const 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: + performerIDs.length === 0 + ? stashScene.performers.map((p) => p.id) + : performerIDs, + 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 ?? [], + }; + + if (currentSource?.stashboxEndpoint && scene.remote_site_id) { + sceneCreateInput.stash_ids = [ + ...(stashScene?.stash_ids ?? []), + { + endpoint: currentSource.stashboxEndpoint, + stash_id: scene.remote_site_id, + }, + ]; + } + + await saveScene(sceneCreateInput); + } + + 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); + } const sceneTitle = scene.url ? ( = ({ ); - const saveEnabled = - Object.keys(performers ?? []).length === - scene.performers.filter((p) => p.gender !== "MALE" || showMales).length && - Object.keys(performers ?? []).every((id) => performers?.[id].type) && - saveState === ""; - - const endpointBase = endpoint.match(/https?:\/\/.*?\//)?.[0]; - const stashBoxURL = endpointBase - ? `${endpointBase}scenes/${scene.stash_id}` - : ""; - // constants to get around dot-notation eslint rule const fields = { cover_image: "cover_image", @@ -243,175 +315,328 @@ const StashSearchResult: React.FC = ({ date: "date", url: "url", details: "details", + studio: "studio", + }; + + const maybeRenderCoverImage = () => { + if (scene.image) { + return ( + + ); + } + }; + + const renderTitle = () => ( +

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

+ ); + + 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 renderStudioField = () => ( +
+ {/* */} +
+ + {FormUtils.renderLabel({ + title: `${intl.formatMessage({ id: "studio" })}:`, + })} + + { + setStudioID(items[0]?.id); + }} + ids={studioID ? [studioID] : []} + /> + + +
+ {scene.studio && !scene.studio.stored_id && ( + { + showStudioModal(scene.studio!); + }} + > + {scene.studio.name} + + + )} +
+ ); + + const renderPerformerField = () => ( +
+
+ + {FormUtils.renderLabel({ + title: `${intl.formatMessage({ id: "performers" })}:`, + })} + + {/* {performers.map(performer => ( + + {} + } + key={`${performer.name ?? performer.remote_site_id ?? ""}`} + /> + ))} */} + + { + setPerformerIDs(items.map((i) => i.id)); + }} + ids={performerIDs} + /> + + +
+ {performers + ?.filter((p) => !p.stored_id) + .map((p) => ( + { + showPerformerModal(p); + }} + > + {p.name} + + + ))} +
+ ); + + 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)} - > - - -
    - )} + {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} - - - ))} -
    + {renderStudioField()} + {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 00000000000..5911dab6fd6 --- /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 "./taggerContext"; + +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/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index eaeb9165623..ee66dba9b4f 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -1,18 +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, useConfiguration } from "src/core/StashService"; -import { Manual } from "src/components/Help/Manual"; - import { SceneQueue } from "src/models/sceneQueue"; +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 "./taggerContext"; 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[]; @@ -20,162 +17,221 @@ interface ITaggerProps { } export const Tagger: React.FC = ({ scenes, queue }) => { - const stashConfig = useConfiguration(); - const [{ data: config }, setConfig] = useLocalForage( - LOCAL_FORAGE_KEY, - initialConfig - ); + const { + sources, + setCurrentSource, + currentSource, + doSceneQuery, + doSceneFragmentScrape, + doMultiSceneFragmentScrape, + stopMultiScrape, + searchResults, + loading, + loadingMulti, + multiError, + submitFingerprints, + pendingFingerprints, + } = useContext(TaggerStateContext); + const [showConfig, setShowConfig] = useState(false); - const [showManual, setShowManual] = useState(false); + const [hideUnmatched, setHideUnmatched] = useState(false); - const clearSubmissionQueue = (endpoint: string) => { - if (!config) return; + const intl = useIntl(); - setConfig({ - ...config, - fingerprintQueue: { - ...config.fingerprintQueue, - [endpoint]: [], - }, - }); - }; + function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) { + return queue + ? queue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; + } - const [ - submitFingerprints, - { loading: submittingFingerprints }, - ] = GQL.useSubmitStashBoxFingerprintsMutation(); - - const handleFingerprintSubmission = (endpoint: string) => { - if (!config) return; - - return submitFingerprints({ - variables: { - input: { - stash_box_index: getEndpointIndex(endpoint), - scene_ids: config?.fingerprintQueue[endpoint], - }, - }, - }).then(() => { - clearSubmissionQueue(endpoint); - }); - }; + function handleSourceSelect(e: React.ChangeEvent) { + setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value)); + } - if (!config) return ; - - const savedEndpointIndex = - stashConfig.data?.configuration.general.stashBoxes.findIndex( - (s) => s.endpoint === config.selectedEndpoint - ) ?? -1; - const selectedEndpointIndex = - savedEndpointIndex === -1 && - stashConfig.data?.configuration.general.stashBoxes.length - ? 0 - : savedEndpointIndex; - const selectedEndpoint = - stashConfig.data?.configuration.general.stashBoxes[selectedEndpointIndex]; - - function getEndpointIndex(endpoint: string) { + function renderSourceSelector() { return ( - stashConfig.data?.configuration.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); + + 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", + }); + } + + return ( + { + await doSceneQuery(scene.id, v); + } + : undefined + } + scrapeSceneFragment={ + currentSource?.supportSceneFragment + ? async () => { + await doSceneFragmentScrape(scene.id); + } + : undefined + } + > + {searchResult && searchResult.results?.length ? ( + + ) : undefined} + + ); }); - }; - - const getQueue = (endpoint: string) => { - if (!config) return []; - return config.fingerprintQueue[endpoint] ?? []; - }; + } - const fingerprintQueue = { - queueFingerprintSubmission, - getQueue, - submitFingerprints: handleFingerprintSubmission, - submittingFingerprints, + const toggleHideUnmatchedScenes = () => { + setHideUnmatched(!hideUnmatched); }; - return ( - <> - setShowManual(false)} - defaultActiveTab="Tagger.md" - /> -
    - {selectedEndpointIndex !== -1 && selectedEndpoint ? ( - <> -
    - - -
    + ), + }} + /> + + ); + } + } - - + + + + + ); + } + } + + 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} - ) : ( -
    -

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

    -
    - Please see{" "} - - el.scrollIntoView({ behavior: "smooth", block: "center" }) - } - > - Settings. - -
    -
    )}
    - + ); + } + + 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 5edba84b0d8..00000000000 --- 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 3f4019522ba..0578363e992 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 "./taggerContext"; +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 queryString = useRef(""); + const { config } = useContext(TaggerStateContext); + const [queryString, setQueryString] = useState(""); + const [queryLoading, setQueryLoading] = useState(false); - 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) - } - /> - - - - - ); + try { + setQueryLoading(true); + await doSceneQuery(queryString || defaultQueryString); + } finally { + setQueryLoading(false); } + } - if (taggedScene) { - return ( -
    -
    - -
    -
    - - {taggedScene.title} - -
    -
    - ); - } + function renderQueryForm() { + if (!doSceneQuery) return; + + return ( + + + + + + + ) => { + setQueryString(e.currentTarget.value); + }} + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && query() + } + /> + + + + + + + ); } - function renderSubContent() { + function maybeRenderStashLinks() { if (scene.stash_ids.length > 0) { const stashLinks = scene.stash_ids.map((stashID) => { const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; @@ -220,57 +172,11 @@ export const TaggerScene: React.FC = ({ 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 5b78060c8ad..3485e810a90 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/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 82e9cc62263..1365ae4b0d0 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 { Manual } from "src/components/Help/Manual"; 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 cc74777d99e..cd97d2dfa94 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 00000000000..1790fec1852 --- /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 "./taggerContext"; + +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 ed38e7d74b5..3a4d10cbd86 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; @@ -230,11 +241,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 +259,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/taggerContext.tsx b/ui/v2.5/src/components/Tagger/taggerContext.tsx new file mode 100644 index 00000000000..834fbc2f610 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/taggerContext.tsx @@ -0,0 +1,629 @@ +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 { + queryScrapeScene, + queryScrapeSceneQuery, + queryScrapeSceneQueryFragment, + stashBoxSceneBatchQuery, + useConfiguration, + useListSceneScrapers, + usePerformerCreate, + useSceneUpdate, + useStudioCreate, + useTagCreate, +} from "src/core/StashService"; +import { useLocalForage, useToast } from "src/hooks"; +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; + createNewStudio: ( + toCreate: GQL.StudioCreateInput + ) => Promise; + resolveScene: ( + sceneID: string, + index: number, + scene: IScrapedScene + ) => Promise; + submitFingerprints: () => Promise; + pendingFingerprints: string[]; + saveScene: (sceneCreateInput: GQL.SceneUpdateInput) => 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, + createNewStudio: dummyValFn, + 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 stashConfig = useConfiguration(); + const Scrapers = useListSceneScrapers(); + + const Toast = useToast(); + const [createTag] = useTagCreate(); + const [createPerformer] = usePerformerCreate(); + const [createStudio] = useStudioCreate(); + const [updateScene] = useSceneUpdate(); + + useEffect(() => { + if (!stashConfig.data || !Scrapers.data) { + return; + } + + const { stashBoxes } = stashConfig.data.configuration.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.data]); + + 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; + // 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((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); + } 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()); + } + } 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) { + try { + await updateScene({ + variables: { + input: sceneCreateInput, + }, + }); + + 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 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); + } + } + + return ( + { + setCurrentSource(src); + }, + doSceneQuery, + doSceneFragmentScrape, + doMultiSceneFragmentScrape, + stopMultiScrape, + createNewTag, + createNewPerformer, + createNewStudio, + resolveScene, + saveScene, + submitFingerprints, + pendingFingerprints: getPendingFingerprints(), + }} + > + {children} + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 8ba60a5d5ef..d4cfe43dae5 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/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 5edfa4ac276..a577ad9b969 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" }, @@ -136,6 +139,7 @@ }, "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" From aa79b61c039f609e808e8ca8df09fb78f778642d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 6 Oct 2021 13:12:00 +1100 Subject: [PATCH 02/12] Use PerformerResult --- .../Scraper/SceneScraperSearchResult.tsx | 95 ------- .../src/components/Tagger/PerformerResult.tsx | 137 +++------- .../components/Tagger/StashSearchResult.tsx | 43 ++-- .../src/components/Tagger/taggerService.ts | 238 ------------------ 4 files changed, 59 insertions(+), 454 deletions(-) delete mode 100644 ui/v2.5/src/components/Tagger/taggerService.ts diff --git a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx index 0779a441934..6ef33d7c6c9 100644 --- a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx +++ b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx @@ -1,104 +1,9 @@ import React, { useState, useEffect } from "react"; -import { Badge, Col, Row } from "react-bootstrap"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; -import { TruncatedText } from "src/components/Shared"; import SceneScraperSceneEditor from "./SceneScraperSceneEditor"; -interface ISceneSearchResultDetailsProps { - scene: GQL.ScrapedSceneDataFragment; -} - -const SceneSearchResultDetails: React.FC = ({ - scene, -}) => { - function renderPerformers() { - if (scene.performers) { - return ( - - - {scene.performers?.map((performer) => ( - - {performer.name} - - ))} - - - ); - } - } - - function renderTags() { - if (scene.tags) { - return ( - - - {scene.tags?.map((tag) => ( - - {tag.name} - - ))} - - - ); - } - } - - function renderImage() { - if (scene.image) { - return ( -
    - -
    - ); - } - } - - return ( -
    - - {renderImage()} -
    -

    {scene.title}

    -
    - {scene.studio?.name} - {scene.studio?.name && scene.date && ` • `} - {scene.date} -
    -
    -
    - - - - - - {renderPerformers()} - {renderTags()} -
    - ); -}; - -interface ISceneSearchResult { - scene: GQL.ScrapedSceneDataFragment; -} - -// TODO - decide if we want to keep this -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const SceneSearchResult: React.FC = ({ scene }) => { - return ( -
    -
    - -
    -
    - ); -}; - export interface ISceneSearchResults { target: GQL.SlimSceneDataFragment; scenes: GQL.ScrapedSceneDataFragment[]; diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx index 12e23342ffa..54b810ae70c 100755 --- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -1,4 +1,4 @@ -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"; @@ -6,115 +6,52 @@ import cx from "classnames"; import { PerformerSelect } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { ValidTypes } from "src/components/Shared/Select"; -import { IStashBoxPerformer } 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; -} - interface IPerformerResultProps { - performer: IStashBoxPerformer; - setPerformer: (data: PerformerOperation) => void; - endpoint: string; + performer: GQL.ScrapedPerformer; + selectedID: string | undefined; + setSelectedID: (id: string | undefined) => void; + onCreate: () => void; + endpoint?: string; } const PerformerResult: React.FC = ({ performer, - setPerformer, + selectedID, + setSelectedID, + onCreate, 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 +60,33 @@ const PerformerResult: React.FC = ({
    - v ? handlePerformerSkip() : setSelectedSource("existing") + v ? handlePerformerSkip() : setSelectedID(matchedPerformer.id) } >
    : - - {stashData.findPerformers.performers[0].name} - + {matchedPerformer.name}
    ); } + + const selectedSource = !selectedID ? "skip" : "existing"; + return (
    - showModal(false)} - modalVisible={modalVisible} - performer={performer} - onSave={() => {}} - icon="star" - header="Create Performer" - create - endpoint={endpoint} - />
    : {performer.name}
    - = ({ }, [stashScene, scene, config]); const getInitialPerformers = useCallback(() => { - // default to override existing - return performers.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? []; + return performers.map((p) => p.stored_id ?? undefined); }, [performers]); const getInitialStudio = useCallback(() => { @@ -175,9 +174,12 @@ const StashSearchResult: React.FC = ({ {} ); const [tagIDs, setTagIDs] = useState(getInitialTags()); - const [performerIDs, setPerformerIDs] = useState( + + // map of original performer to id + const [performerIDs, setPerformerIDs] = useState<(string | undefined)[]>( getInitialPerformers() ); + const [studioID, setStudioID] = useState( getInitialStudio() ); @@ -244,15 +246,19 @@ const StashSearchResult: React.FC = ({ } } + 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: - performerIDs.length === 0 + filteredPerformerIDs.length === 0 ? stashScene.performers.map((p) => p.id) - : performerIDs, + : filteredPerformerIDs, studio_id: studioID, cover_image: resolveField("cover_image", undefined, imgData), url: resolveField("url", stashScene.url, scene.url), @@ -461,6 +467,12 @@ const StashSearchResult: React.FC = ({
    ); + function setPerformerID(performerIndex: number, id: string | undefined) { + const newPerformerIDs = [...performerIDs]; + newPerformerIDs[performerIndex] = id; + setPerformerIDs(newPerformerIDs); + } + const renderPerformerField = () => (
    @@ -469,27 +481,28 @@ const StashSearchResult: React.FC = ({ title: `${intl.formatMessage({ id: "performers" })}:`, })} - {/* {performers.map(performer => ( + {performers.map((performer, performerIndex) => ( - {} - } + selectedID={performerIDs[performerIndex]} + setSelectedID={(id) => setPerformerID(performerIndex, id)} + onCreate={() => showPerformerModal(performer)} + endpoint={currentSource?.stashboxEndpoint} key={`${performer.name ?? performer.remote_site_id ?? ""}`} /> - ))} */} + ))} - { setPerformerIDs(items.map((i) => i.id)); }} ids={performerIDs} - /> + /> */}
    - {performers + {/* {performers ?.filter((p) => !p.stored_id) .map((p) => ( = ({ - ))} + ))} */}
    ); 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 3f41f8bde14..00000000000 --- 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; -} From 0866acfea2dfbc9571e2154c12b588242ab66f0d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 6 Oct 2021 15:20:43 +1100 Subject: [PATCH 03/12] Support link to existing studio/performer --- .../src/components/Shared/OperationButton.tsx | 19 +- .../src/components/Tagger/PerformerResult.tsx | 21 +- .../components/Tagger/StashSearchResult.tsx | 112 ++++------- .../src/components/Tagger/StudioResult.tsx | 185 ++++++------------ .../src/components/Tagger/taggerContext.tsx | 135 +++++++++++++ ui/v2.5/src/core/StashService.ts | 15 ++ 6 files changed, 284 insertions(+), 203 deletions(-) diff --git a/ui/v2.5/src/components/Shared/OperationButton.tsx b/ui/v2.5/src/components/Shared/OperationButton.tsx index c3c60666c8a..7cdc831195f 100644 --- a/ui/v2.5/src/components/Shared/OperationButton.tsx +++ b/ui/v2.5/src/components/Shared/OperationButton.tsx @@ -1,23 +1,33 @@ -import React, { useState } from "react"; +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; @@ -26,7 +36,10 @@ export const OperationButton: React.FC = (props) => { if (operation) { setLoading(true); await operation(); - setLoading(false); + + if (mounted.current) { + setLoading(false); + } } } @@ -37,7 +50,7 @@ export const OperationButton: React.FC = (props) => { )} - {props.children} + {(!loading || !hideChildrenWhenLoading) && props.children} ); }; diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx index 54b810ae70c..07b615cc779 100755 --- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -3,17 +3,19 @@ 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 { OptionalField } from "./IncludeButton"; +import { OperationButton } from "../Shared/OperationButton"; interface IPerformerResultProps { performer: GQL.ScrapedPerformer; selectedID: string | undefined; setSelectedID: (id: string | undefined) => void; onCreate: () => void; + onLink?: () => Promise; endpoint?: string; } @@ -22,6 +24,7 @@ const PerformerResult: React.FC = ({ selectedID, setSelectedID, onCreate, + onLink, endpoint, }) => { const { @@ -77,6 +80,21 @@ const PerformerResult: React.FC = ({ ); } + function maybeRenderLinkButton() { + if (endpoint && onLink) { + return ( + + + + ); + } + } + const selectedSource = !selectedID ? "skip" : "existing"; return ( @@ -103,6 +121,7 @@ const PerformerResult: React.FC = ({ })} isClearable={false} /> + {maybeRenderLinkButton()}
    ); diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index dd7634a1981..c0b3eff6c5b 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -7,7 +7,6 @@ import * as GQL from "src/core/generated-graphql"; import { Icon, LoadingIndicator, - StudioSelect, SuccessIcon, TagSelect, TruncatedText, @@ -21,6 +20,7 @@ import { IScrapedScene, TaggerStateContext } from "./taggerContext"; import { OperationButton } from "../Shared/OperationButton"; import { SceneTaggerModalsState } from "./sceneTaggerModals"; import PerformerResult from "./PerformerResult"; +import StudioResult from "./StudioResult"; const getDurationStatus = ( scene: IScrapedScene, @@ -117,7 +117,9 @@ const StashSearchResult: React.FC = ({ config, createNewTag, createNewPerformer, + linkPerformer, createNewStudio, + linkStudio, resolveScene, currentSource, saveScene, @@ -431,41 +433,24 @@ const StashSearchResult: React.FC = ({ } }; - const renderStudioField = () => ( -
    - {/* */} -
    - - {FormUtils.renderLabel({ - title: `${intl.formatMessage({ id: "studio" })}:`, - })} - - { - setStudioID(items[0]?.id); - }} - ids={studioID ? [studioID] : []} - /> - - -
    - {scene.studio && !scene.studio.stored_id && ( - { - showStudioModal(scene.studio!); - }} - > - {scene.studio.name} - - - )} -
    - ); + 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]; @@ -476,49 +461,22 @@ const StashSearchResult: React.FC = ({ const renderPerformerField = () => (
    - - {FormUtils.renderLabel({ - title: `${intl.formatMessage({ id: "performers" })}:`, - })} - - {performers.map((performer, performerIndex) => ( - setPerformerID(performerIndex, id)} - onCreate={() => showPerformerModal(performer)} - endpoint={currentSource?.stashboxEndpoint} - key={`${performer.name ?? performer.remote_site_id ?? ""}`} - /> - ))} - - {/* { - setPerformerIDs(items.map((i) => i.id)); + + {performers.map((performer, performerIndex) => ( + setPerformerID(performerIndex, id)} + onCreate={() => showPerformerModal(performer)} + onLink={async () => { + await linkPerformer(performer, performerIDs[performerIndex]!); }} - ids={performerIDs} - /> */} - + endpoint={currentSource?.stashboxEndpoint} + key={`${performer.name ?? performer.remote_site_id ?? ""}`} + /> + ))}
    - {/* {performers - ?.filter((p) => !p.stored_id) - .map((p) => ( - { - showPerformerModal(p); - }} - > - {p.name} - - - ))} */}
    ); @@ -593,7 +551,7 @@ const StashSearchResult: React.FC = ({
    {isActive && (
    - {renderStudioField()} + {maybeRenderStudioField()} {renderPerformerField()} {renderTagsField()} diff --git a/ui/v2.5/src/components/Tagger/StudioResult.tsx b/ui/v2.5/src/components/Tagger/StudioResult.tsx index 69690e85302..c7f4e3fadf3 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 (loadingStashID) return
    Loading studio
    ; + if (stashLoading) 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/taggerContext.tsx b/ui/v2.5/src/components/Tagger/taggerContext.tsx index 834fbc2f610..94a4550b9f7 100644 --- a/ui/v2.5/src/components/Tagger/taggerContext.tsx +++ b/ui/v2.5/src/components/Tagger/taggerContext.tsx @@ -6,6 +6,8 @@ import { } from "src/components/Tagger/constants"; import * as GQL from "src/core/generated-graphql"; import { + queryFindPerformer, + queryFindStudio, queryScrapeScene, queryScrapeSceneQuery, queryScrapeSceneQueryFragment, @@ -13,8 +15,10 @@ import { useConfiguration, useListSceneScrapers, usePerformerCreate, + usePerformerUpdate, useSceneUpdate, useStudioCreate, + useStudioUpdate, useTagCreate, } from "src/core/StashService"; import { useLocalForage, useToast } from "src/hooks"; @@ -38,9 +42,14 @@ export interface ITaggerContextState { 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, @@ -71,7 +80,9 @@ export const TaggerStateContext = React.createContext({ stopMultiScrape: () => {}, createNewTag: dummyValFn, createNewPerformer: dummyValFn, + linkPerformer: dummyFn, createNewStudio: dummyValFn, + linkStudio: dummyFn, resolveScene: dummyFn, submitFingerprints: dummyFn, pendingFingerprints: [], @@ -108,7 +119,9 @@ export const TaggerContext: React.FC = ({ children }) => { const Toast = useToast(); const [createTag] = useTagCreate(); const [createPerformer] = usePerformerCreate(); + const [updatePerformer] = usePerformerUpdate(); const [createStudio] = useStudioCreate(); + const [updateStudio] = useStudioUpdate(); const [updateScene] = useSceneUpdate(); useEffect(() => { @@ -553,6 +566,69 @@ export const TaggerContext: React.FC = ({ children }) => { } } + 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({ @@ -596,6 +672,63 @@ export const TaggerContext: React.FC = ({ children }) => { } } + 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 ( { stopMultiScrape, createNewTag, createNewPerformer, + linkPerformer, createNewStudio, + linkStudio, resolveScene, saveScene, submitFingerprints, diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 2a2e4abd4fc..97f9d94d3de 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 }); From d8ad8cc1ecc075307b789f3921f7aca11b710cb7 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 6 Oct 2021 15:38:32 +1100 Subject: [PATCH 04/12] Scrape by fragment is already resolved --- ui/v2.5/src/components/Tagger/taggerContext.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/taggerContext.tsx b/ui/v2.5/src/components/Tagger/taggerContext.tsx index 94a4550b9f7..7ecfc14cf89 100644 --- a/ui/v2.5/src/components/Tagger/taggerContext.tsx +++ b/ui/v2.5/src/components/Tagger/taggerContext.tsx @@ -285,8 +285,6 @@ export const TaggerContext: React.FC = ({ children }) => { const results = await queryScrapeScene(currentSource.sourceInput, sceneID); 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 }; @@ -296,7 +294,8 @@ export const TaggerContext: React.FC = ({ children }) => { newResult = { results: results.data.scrapeSingleScene.map((r) => ({ ...r, - resolved, + // scenes are already resolved if they are scraped via fragment + resolved: true, })), }; } From 89534a6e476433f26595feccca0a55761de3d099 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 6 Oct 2021 15:38:41 +1100 Subject: [PATCH 05/12] Styling --- ui/v2.5/src/components/Tagger/Tagger.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index ee66dba9b4f..8b4498fc5c3 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -219,9 +219,9 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
    -
    - {renderSourceSelector()} -
    +
    +
    {renderSourceSelector()}
    +
    {maybeRenderShowHideUnmatchedButton()} {maybeRenderSubmitFingerprintsButton()} {renderFragmentScrapeButton()} From 76b5786ec1f4f2940cf8bdffcc572402adf713e6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 6 Oct 2021 16:05:00 +1100 Subject: [PATCH 06/12] Clean up --- ui/v2.5/src/components/Scenes/SceneList.tsx | 5 +- .../src/components/Scenes/Scraper/Config.tsx | 218 ------ .../Scenes/Scraper/PerformerModal.tsx | 227 ------- .../Scenes/Scraper/SceneScraper.tsx | 237 ------- .../Scenes/Scraper/SceneScraperScene.tsx | 225 ------- .../Scraper/SceneScraperSceneEditor.tsx | 590 ---------------- .../Scraper/SceneScraperSearchResult.tsx | 51 -- .../components/Scenes/Scraper/StudioModal.tsx | 114 ---- .../components/Scenes/Scraper/constants.ts | 13 - .../src/components/Scenes/Scraper/context.tsx | 629 ------------------ .../src/components/Scenes/Scraper/modals.tsx | 123 ---- ui/v2.5/src/components/Tagger/Config.tsx | 2 +- .../src/components/Tagger/IncludeButton.tsx | 5 +- .../components/Tagger/StashSearchResult.tsx | 2 +- ui/v2.5/src/components/Tagger/StudioModal.tsx | 2 +- ui/v2.5/src/components/Tagger/Tagger.tsx | 2 +- ui/v2.5/src/components/Tagger/TaggerScene.tsx | 2 +- .../Tagger/{taggerContext.tsx => context.tsx} | 0 .../components/Tagger/sceneTaggerModals.tsx | 2 +- 19 files changed, 9 insertions(+), 2440 deletions(-) delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/Config.tsx delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/PerformerModal.tsx delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/SceneScraper.tsx delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/SceneScraperScene.tsx delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/SceneScraperSceneEditor.tsx delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/StudioModal.tsx delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/constants.ts delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/context.tsx delete mode 100644 ui/v2.5/src/components/Scenes/Scraper/modals.tsx rename ui/v2.5/src/components/Tagger/{taggerContext.tsx => context.tsx} (100%) diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 043e1244265..ec3b68dd343 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -13,7 +13,7 @@ import { useScenesList } from "src/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook"; -// import Tagger from "src/components/Tagger"; +import Tagger from "src/components/Tagger"; import { SceneQueue } from "src/models/sceneQueue"; import { WallPanel } from "../Wall/WallPanel"; import { SceneListTable } from "./SceneListTable"; @@ -22,8 +22,7 @@ import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { SceneGenerateDialog } from "./SceneGenerateDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { SceneCardsGrid } from "./SceneCardsGrid"; -import Tagger from "../Tagger"; -import { TaggerContext } from "../Tagger/taggerContext"; +import { TaggerContext } from "../Tagger/context"; interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; diff --git a/ui/v2.5/src/components/Scenes/Scraper/Config.tsx b/ui/v2.5/src/components/Scenes/Scraper/Config.tsx deleted file mode 100644 index 5f2da9a293e..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/Config.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { useRef, useContext } from "react"; -import { - Badge, - Button, - Card, - Collapse, - Form, - InputGroup, -} from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import { Icon } from "src/components/Shared"; -import { ParseMode, TagOperation } from "src/components/Tagger/constants"; -import { SceneScraperStateContext } from "./context"; - -interface IConfigProps { - show: boolean; -} - -const Config: React.FC = ({ show }) => { - const { config, setConfig } = useContext(SceneScraperStateContext); - const intl = useIntl(); - const blacklistRef = useRef(null); - - const removeBlacklist = (index: number) => { - setConfig({ - ...config, - blacklist: [ - ...config.blacklist.slice(0, index), - ...config.blacklist.slice(index + 1), - ], - }); - }; - - const handleBlacklistAddition = () => { - if (!blacklistRef.current) return; - - const input = blacklistRef.current.value; - if (input.length === 0) return; - - setConfig({ - ...config, - blacklist: [...config.blacklist, input], - }); - blacklistRef.current.value = ""; - }; - - return ( - - -
    -

    - -

    -
    -
    - - - } - checked={config.showMales} - onChange={(e: React.ChangeEvent) => - setConfig({ ...config, showMales: e.currentTarget.checked }) - } - /> - - - - - - - } - checked={config.setCoverImage} - onChange={(e: React.ChangeEvent) => - setConfig({ - ...config, - setCoverImage: e.currentTarget.checked, - }) - } - /> - - - - - -
    - - } - className="mr-4" - checked={config.setTags} - onChange={(e: React.ChangeEvent) => - setConfig({ ...config, setTags: e.currentTarget.checked }) - } - /> - ) => - setConfig({ - ...config, - tagOperation: e.currentTarget.value as TagOperation, - }) - } - disabled={!config.setTags} - > - - - -
    - - - -
    - - -
    - - - : - - ) => - setConfig({ - ...config, - mode: e.currentTarget.value as ParseMode, - }) - } - > - - - - - - -
    - - {intl.formatMessage({ - id: `component_tagger.config.query_mode_${config.mode}_desc`, - defaultMessage: "Unknown query mode", - })} - -
    -
    -
    -
    - -
    - - - - - - -
    - {intl.formatMessage( - { id: "component_tagger.config.blacklist_desc" }, - { chars_require_escape: [\^$.|?*+() } - )} -
    - {config.blacklist.map((item, index) => ( - - {item.toString()} - - - ))} -
    -
    -
    -
    - ); -}; - -export default Config; diff --git a/ui/v2.5/src/components/Scenes/Scraper/PerformerModal.tsx b/ui/v2.5/src/components/Scenes/Scraper/PerformerModal.tsx deleted file mode 100644 index 96f7c4247eb..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/PerformerModal.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { useState, useContext } from "react"; -import { Button } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; -import cx from "classnames"; -import { IconName } from "@fortawesome/fontawesome-svg-core"; - -import { - LoadingIndicator, - Icon, - Modal, - TruncatedText, -} from "src/components/Shared"; -import * as GQL from "src/core/generated-graphql"; -import { genderToString, stringToGender } from "src/utils/gender"; -import { SceneScraperStateContext } from "./context"; - -interface IPerformerModalProps { - performer: GQL.ScrapedScenePerformerDataFragment; - modalVisible: boolean; - closeModal: () => void; - handlePerformerCreate: (input: GQL.PerformerCreateInput) => void; - header: string; - icon: IconName; -} - -const PerformerModal: React.FC = ({ - modalVisible, - performer, - handlePerformerCreate, - closeModal, - header, - icon, -}) => { - const { currentSource } = useContext(SceneScraperStateContext); - const intl = useIntl(); - const [imageIndex, setImageIndex] = useState(0); - const [imageState, setImageState] = useState< - "loading" | "error" | "loaded" | "empty" - >("empty"); - const [loadDict, setLoadDict] = useState>({}); - - const images = performer.images ?? []; - - const changeImage = (index: number) => { - setImageIndex(index); - if (!loadDict[index]) setImageState("loading"); - }; - const setPrev = () => - changeImage(imageIndex === 0 ? images.length - 1 : imageIndex - 1); - const setNext = () => - changeImage(imageIndex === images.length - 1 ? 0 : imageIndex + 1); - - const handleLoad = (index: number) => { - setLoadDict({ - ...loadDict, - [index]: true, - }); - setImageState("loaded"); - }; - const handleError = () => setImageState("error"); - - const renderField = ( - id: string, - text: string | null | undefined, - truncate: boolean = true - ) => - text && ( -
    -
    - - : - -
    - {truncate ? ( - - ) : ( - {text} - )} -
    - ); - - function onSave() { - 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 && currentSource?.stashboxEndpoint) { - performerData.stash_ids = [ - { - endpoint: currentSource.stashboxEndpoint, - stash_id: remoteSiteID, - }, - ]; - } - - handlePerformerCreate(performerData); - } - - const base = currentSource?.stashboxEndpoint?.match(/https?:\/\/.*?\//)?.[0]; - const link = base - ? `${base}performers/${performer.remote_site_id}` - : undefined; - - return ( - closeModal(), variant: "secondary" }} - onHide={() => closeModal()} - dialogClassName="performer-create-modal" - icon={icon} - header={header} - > -
    -
    - {renderField("name", performer.name)} - {renderField( - "gender", - performer.gender ? genderToString(performer.gender) : "" - )} - {renderField("birthdate", performer.birthdate)} - {renderField("death_date", performer.death_date)} - {renderField("ethnicity", performer.ethnicity)} - {renderField("country", performer.country)} - {renderField("hair_color", performer.hair_color)} - {renderField("eye_color", performer.eye_color)} - {renderField("height", performer.height)} - {renderField("weight", performer.weight)} - {renderField("measurements", performer.measurements)} - {performer?.gender !== GQL.GenderEnum.Male && - renderField("fake_tits", performer.fake_tits)} - {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 && ( -
    - - Stash-Box Source - - -
    - )} -
    - {images.length > 0 && ( -
    -
    - handleLoad(imageIndex)} - onError={handleError} - /> - {imageState === "loading" && ( - - )} - {imageState === "error" && ( -
    - Error loading image. -
    - )} -
    -
    - -
    - Select performer image -
    - {imageIndex + 1} of {images.length} -
    - -
    -
    - )} -
    -
    - ); -}; - -export default PerformerModal; diff --git a/ui/v2.5/src/components/Scenes/Scraper/SceneScraper.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraper.tsx deleted file mode 100644 index d948ccb1cee..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/SceneScraper.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import React, { useContext, useState } from "react"; -import * as GQL from "src/core/generated-graphql"; -import { SceneQueue } from "src/models/sceneQueue"; -import { Button, Card, 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 { SceneScraperScene } from "./SceneScraperScene"; -import { SceneScraperStateContext } from "./context"; -import { SceneSearchResults } from "./SceneScraperSearchResult"; -import { SceneScraperModals } from "./modals"; -import Config from "./Config"; - -interface IScraperProps { - scenes: GQL.SlimSceneDataFragment[]; - queue?: SceneQueue; -} - -export const SceneScraper: React.FC = ({ scenes, queue }) => { - const { - sources, - setCurrentSource, - currentSource, - doSceneQuery, - doSceneFragmentScrape, - doMultiSceneFragmentScrape, - stopMultiScrape, - searchResults, - loading, - loadingMulti, - multiError, - submitFingerprints, - pendingFingerprints, - } = useContext(SceneScraperStateContext); - - const [showConfig, setShowConfig] = useState(false); - const [hideUnmatched, setHideUnmatched] = useState(false); - - const intl = useIntl(); - - function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) { - return queue - ? queue.makeLink(scene.id, { sceneIndex: index }) - : `/scenes/${scene.id}`; - } - - function handleSourceSelect(e: React.ChangeEvent) { - setCurrentSource(sources!.find((s) => s.id === e.currentTarget.value)); - } - - function renderSourceSelector() { - return ( - - - - -
    - - {!sources.length && } - {sources.map((i) => ( - - ))} - -
    -
    - ); - } - - function renderConfigButton() { - return ( -
    - -
    - ); - } - - function renderScenes() { - const filteredScenes = !hideUnmatched - ? scenes - : scenes.filter((s) => searchResults[s.id]?.results?.length); - - return filteredScenes.map((scene, index) => { - const sceneLink = generateSceneLink(scene, index); - let errorMessage: string | undefined; - const searchResult = searchResults[scene.id]; - if (searchResult?.error) { - errorMessage = searchResult.error; - } else if (searchResult && searchResult.results?.length === 0) { - errorMessage = intl.formatMessage({ - id: "component_tagger.results.match_failed_no_result", - }); - } - - return ( - { - await doSceneQuery(scene.id, v); - } - : undefined - } - scrapeSceneFragment={ - currentSource?.supportFragment - ? 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?.supportFragment) { - 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/Scenes/Scraper/SceneScraperScene.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperScene.tsx deleted file mode 100644 index 784b2016606..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperScene.tsx +++ /dev/null @@ -1,225 +0,0 @@ -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 { parsePath, prepareQueryString } from "src/components/Tagger/utils"; -import { OperationButton } from "src/components/Shared/OperationButton"; -import { ScenePreview } from "../SceneCard"; -import { SceneScraperStateContext } from "./context"; - -interface IScraperSceneDetails { - scene: GQL.SlimSceneDataFragment; -} - -const ScraperSceneDetails: React.FC = ({ scene }) => { - const [open, setOpen] = useState(false); - const sorted = sortPerformers(scene.performers); - - return ( -
    - -
    -
    -

    {scene.title}

    -
    - {scene.studio?.name} - {scene.studio?.name && scene.date && ` • `} - {scene.date} -
    - -
    -
    -
    - {sorted.map((performer) => ( -
    - - {performer.name - - -
    - ))} -
    -
    - {scene.tags.map((tag) => ( - - ))} -
    -
    -
    -
    - -
    - ); -}; - -interface ISceneScraperSceneProps { - scene: GQL.SlimSceneDataFragment; - url: string; - errorMessage?: string; - doSceneQuery?: (queryString: string) => void; - scrapeSceneFragment?: (scene: GQL.SlimSceneDataFragment) => void; - loading?: boolean; -} - -export const SceneScraperScene: React.FC< - PropsWithChildren -> = ({ - scene, - url, - loading, - doSceneQuery, - scrapeSceneFragment, - errorMessage, - children, -}) => { - const { config } = useContext(SceneScraperStateContext); - const [queryString, setQueryString] = useState(""); - const [queryLoading, setQueryLoading] = useState(false); - - const { paths, file } = parsePath(scene.path); - const defaultQueryString = prepareQueryString( - scene, - paths, - file, - config.mode, - config.blacklist - ); - - const width = scene.file.width ? scene.file.width : 0; - const height = scene.file.height ? scene.file.height : 0; - const isPortrait = height > width; - - async function query() { - if (!doSceneQuery) return; - - try { - setQueryLoading(true); - await doSceneQuery(queryString || defaultQueryString); - } finally { - setQueryLoading(false); - } - } - - 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]; - const link = base ? ( - - {stashID.stash_id} - - ) : ( -
    {stashID.stash_id}
    - ); - - return link; - }); - return
    {stashLinks}
    ; - } - } - - return ( -
    -
    -
    -
    - - - -
    - - - -
    -
    -
    - {renderQueryForm()} - {scrapeSceneFragment ? ( -
    - { - await scrapeSceneFragment(scene); - }} - > - - -
    - ) : undefined} -
    - {errorMessage ? ( -
    {errorMessage}
    - ) : undefined} - {maybeRenderStashLinks()} -
    - -
    - {children} -
    - ); -}; diff --git a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSceneEditor.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSceneEditor.tsx deleted file mode 100644 index b6432ea2ea1..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSceneEditor.tsx +++ /dev/null @@ -1,590 +0,0 @@ -import React, { useState, useCallback, useEffect, useMemo } from "react"; -import { Badge, Button, Col, Form, Row } from "react-bootstrap"; -import { FormattedMessage, useIntl } from "react-intl"; - -import * as GQL from "src/core/generated-graphql"; -import { - Icon, - LoadingIndicator, - PerformerSelect, - StudioSelect, - SuccessIcon, - TagSelect, - TruncatedText, -} from "src/components/Shared"; -import { FormUtils } from "src/utils"; -import { uniq } from "lodash"; -import { OptionalField } from "src/components/Tagger/IncludeButton"; -import { stringToGender } from "src/utils/gender"; -import { blobToBase64 } from "base64-blob"; -import { OperationButton } from "src/components/Shared/OperationButton"; -import { IScrapedScene, SceneScraperStateContext } from "./context"; -import { SceneScraperDialogsState } from "./modals"; - -const getDurationStatus = ( - scene: IScrapedScene, - stashDuration: number | undefined | null -) => { - if (!stashDuration) return ""; - - 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; - if (matchCount > 0) - match = ( - - ); - else if (Math.abs(sceneDuration - stashDuration) < 5) - match = ; - - if (match) - return ( -
    - - {match} -
    - ); - - const minDiff = Math.min( - Math.abs(sceneDuration - stashDuration), - ...durations - ); - return ( - - ); -}; - -const getFingerprintStatus = ( - scene: IScrapedScene, - stashScene: GQL.SlimSceneDataFragment -) => { - const checksumMatch = scene.fingerprints?.some( - (f) => f.hash === stashScene.checksum || f.hash === stashScene.oshash - ); - const phashMatch = scene.fingerprints?.some( - (f) => f.hash === stashScene.phash - ); - if (checksumMatch || phashMatch) - return ( -
    - - - ), - }} - /> -
    - ); -}; - -interface IStashSearchResultProps { - scene: IScrapedScene; - stashScene: GQL.SlimSceneDataFragment; - index: number; - isActive: boolean; -} - -const SceneScraperSceneEditor: React.FC = ({ - scene, - stashScene, - index, - isActive, -}) => { - const intl = useIntl(); - - const { - config, - createNewTag, - createNewPerformer, - createNewStudio, - resolveScene, - currentSource, - saveScene, - } = React.useContext(SceneScraperStateContext); - - 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( - SceneScraperDialogsState - ); - - const getInitialTags = useCallback(() => { - const stashSceneTags = stashScene.tags.map((t) => t.id); - if (!config.setTags) { - return stashSceneTags; - } - - const { tagOperation } = config; - - const newTags = - scene.tags?.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? []; - - if (tagOperation === "overwrite") { - return newTags; - } - if (tagOperation === "merge") { - return uniq(stashSceneTags.concat(newTags)); - } - - throw new Error("unexpected tagOperation"); - }, [stashScene, scene, config]); - - const getInitialPerformers = useCallback(() => { - // default to override existing - return performers.filter((t) => t.stored_id).map((t) => t.stored_id!) ?? []; - }, [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 [performerIDs, setPerformerIDs] = useState( - getInitialPerformers() - ); - const [studioID, setStudioID] = useState( - getInitialStudio() - ); - - useEffect(() => { - setTagIDs(getInitialTags()); - }, [getInitialTags]); - - useEffect(() => { - setPerformerIDs(getInitialPerformers()); - }, [getInitialPerformers]); - - useEffect(() => { - setStudioID(getInitialStudio()); - }, [getInitialStudio]); - - useEffect(() => { - async function doResolveScene() { - try { - setLoading(true); - await resolveScene(stashScene.id, index, scene); - } finally { - setLoading(false); - } - } - - if (isActive && !loading && !scene.resolved) { - doResolveScene(); - } - }, [isActive, loading, stashScene, index, resolveScene, scene]); - - // function getExcludedFields() { - // return Object.keys(excludedFields).filter((f) => excludedFields[f]); - // } - - const setExcludedField = (name: string, value: boolean) => - setExcludedFields({ - ...excludedFields, - [name]: value, - }); - - async function handleSave() { - const excludedFieldList = Object.keys(excludedFields).filter( - (f) => excludedFields[f] - ); - - function resolveField(field: string, stashField: T, remoteField: T) { - if (excludedFieldList.includes(field)) { - return stashField; - } - - return remoteField; - } - - let imgData; - if (!excludedFields.cover_image && config.setCoverImage) { - const imgurl = scene.image; - if (imgurl) { - const img = await fetch(imgurl, { - mode: "cors", - cache: "no-store", - }); - if (img.status === 200) { - const blob = await img.blob(); - // Sanity check on image size since bad images will fail - if (blob.size > 10000) imgData = await blobToBase64(blob); - } - } - } - - const 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: - performerIDs.length === 0 - ? stashScene.performers.map((p) => p.id) - : performerIDs, - 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 ?? [], - }; - - if (currentSource?.stashboxEndpoint && scene.remote_site_id) { - sceneCreateInput.stash_ids = [ - ...(stashScene?.stash_ids ?? []), - { - endpoint: currentSource.stashboxEndpoint, - stash_id: scene.remote_site_id, - }, - ]; - } - - await saveScene(sceneCreateInput); - } - - 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); - } - - const sceneTitle = scene.url ? ( - - - - ) : ( - - ); - - // constants to get around dot-notation eslint rule - const fields = { - cover_image: "cover_image", - title: "title", - date: "date", - url: "url", - details: "details", - studio: "studio", - }; - - const maybeRenderCoverImage = () => { - if (scene.image) { - return ( -
    - setExcludedField(fields.cover_image, v)} - > - - - - -
    - ); - } - }; - - const renderTitle = () => ( -

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

    - ); - - 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 renderStudioField = () => ( -
    -
    - - {FormUtils.renderLabel({ - title: `${intl.formatMessage({ id: "studio" })}:`, - })} - - { - setStudioID(items[0]?.id); - }} - ids={studioID ? [studioID] : []} - /> - - -
    - {scene.studio && !scene.studio.stored_id && ( - { - showStudioModal(scene.studio!); - }} - > - {scene.studio.name} - - - )} -
    - ); - - const renderPerformerField = () => ( -
    -
    - - {FormUtils.renderLabel({ - title: `${intl.formatMessage({ id: "performers" })}:`, - })} - - { - setPerformerIDs(items.map((i) => i.id)); - }} - ids={performerIDs} - /> - - -
    - {performers - ?.filter((p) => !p.stored_id) - .map((p) => ( - { - showPerformerModal(p); - }} - > - {p.name} - - - ))} -
    - ); - - 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 ( - <> -
    -
    - {maybeRenderCoverImage()} -
    - {renderTitle()} - - {!isActive && ( - <> - {renderStudioDate()} - {renderPerformerList()} - - )} - - {maybeRenderDateField()} - {getDurationStatus(scene, stashScene.file?.duration)} - {getFingerprintStatus(scene, stashScene)} -
    -
    - {isActive && ( -
    - {maybeRenderURL()} - {maybeRenderDetails()} -
    - )} -
    - {isActive && ( -
    - {renderStudioField()} - {renderPerformerField()} - {renderTagsField()} - -
    - - - -
    -
    - )} - - ); -}; - -export default SceneScraperSceneEditor; diff --git a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx b/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx deleted file mode 100644 index 6ef33d7c6c9..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/SceneScraperSearchResult.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useState, useEffect } from "react"; -import cx from "classnames"; - -import * as GQL from "src/core/generated-graphql"; -import SceneScraperSceneEditor from "./SceneScraperSceneEditor"; - -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/Scenes/Scraper/StudioModal.tsx b/ui/v2.5/src/components/Scenes/Scraper/StudioModal.tsx deleted file mode 100644 index 379b87587f7..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/StudioModal.tsx +++ /dev/null @@ -1,114 +0,0 @@ -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 { SceneScraperStateContext } 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(SceneScraperStateContext); - 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/Scenes/Scraper/constants.ts b/ui/v2.5/src/components/Scenes/Scraper/constants.ts deleted file mode 100644 index 73a5ac059e4..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ScraperSourceInput } from "src/core/generated-graphql"; - -export const STASH_BOX_PREFIX = "stashbox:"; -export const SCRAPER_PREFIX = "scraper:"; - -export interface IScraperSource { - id: string; - stashboxEndpoint?: string; - sourceInput: ScraperSourceInput; - displayName: string; - supportQuery?: boolean; - supportFragment?: boolean; -} diff --git a/ui/v2.5/src/components/Scenes/Scraper/context.tsx b/ui/v2.5/src/components/Scenes/Scraper/context.tsx deleted file mode 100644 index 2b9d9f89403..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/context.tsx +++ /dev/null @@ -1,629 +0,0 @@ -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 { - queryScrapeScene, - queryScrapeSceneQuery, - queryScrapeSceneQueryFragment, - stashBoxSceneBatchQuery, - useConfiguration, - useListSceneScrapers, - usePerformerCreate, - useSceneUpdate, - useStudioCreate, - useTagCreate, -} from "src/core/StashService"; -import { useLocalForage, useToast } from "src/hooks"; -import { IScraperSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants"; - -export interface ISceneScraperContextState { - config: ITaggerConfig; - setConfig: (c: ITaggerConfig) => void; - loading: boolean; - loadingMulti?: boolean; - multiError?: string; - sources: IScraperSource[]; - currentSource?: IScraperSource; - searchResults: Record; - setCurrentSource: (src?: IScraperSource) => 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; - createNewStudio: ( - toCreate: GQL.StudioCreateInput - ) => Promise; - resolveScene: ( - sceneID: string, - index: number, - scene: IScrapedScene - ) => Promise; - submitFingerprints: () => Promise; - pendingFingerprints: string[]; - saveScene: (sceneCreateInput: GQL.SceneUpdateInput) => Promise; -} - -const dummyFn = () => { - return Promise.resolve(); -}; -const dummyValFn = () => { - return Promise.resolve(undefined); -}; - -export const SceneScraperStateContext = React.createContext( - { - config: initialConfig, - setConfig: () => {}, - loading: false, - sources: [], - searchResults: {}, - setCurrentSource: () => {}, - doSceneQuery: dummyFn, - doSceneFragmentScrape: dummyFn, - doMultiSceneFragmentScrape: dummyFn, - stopMultiScrape: () => {}, - createNewTag: dummyValFn, - createNewPerformer: dummyValFn, - createNewStudio: dummyValFn, - resolveScene: dummyFn, - submitFingerprints: dummyFn, - pendingFingerprints: [], - saveScene: dummyFn, - } -); - -export type IScrapedScene = GQL.ScrapedScene & { resolved?: boolean }; - -export interface ISceneQueryResult { - results?: IScrapedScene[]; - error?: string; -} - -export const SceneScraperContext: 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 stashConfig = useConfiguration(); - const Scrapers = useListSceneScrapers(); - - const Toast = useToast(); - const [createTag] = useTagCreate(); - const [createPerformer] = usePerformerCreate(); - const [createStudio] = useStudioCreate(); - const [updateScene] = useSceneUpdate(); - - useEffect(() => { - if (!stashConfig.data || !Scrapers.data) { - return; - } - - const { stashBoxes } = stashConfig.data.configuration.general; - const scrapers = Scrapers.data.listSceneScrapers; - - const stashboxSources: IScraperSource[] = stashBoxes.map((s, i) => ({ - id: `${STASH_BOX_PREFIX}${i}`, - stashboxEndpoint: s.endpoint, - sourceInput: { - stash_box_index: i, - }, - displayName: `stash-box: ${s.name}`, - supportFragment: true, - supportQuery: true, - })); - - // filter scraper sources such that only those that can query scrape or - // scrape via fragment are added - const scraperSources: IScraperSource[] = 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, - supportQuery: s.scene?.supported_scrapes.includes(GQL.ScrapeType.Name), - supportFragment: s.scene?.supported_scrapes.includes( - GQL.ScrapeType.Fragment - ), - })); - - setSources(stashboxSources.concat(scraperSources)); - }, [Scrapers.data, stashConfig.data]); - - 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; - // 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((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); - } 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()); - } - } 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) { - try { - await updateScene({ - variables: { - input: sceneCreateInput, - }, - }); - - 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 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); - } - } - - return ( - { - setCurrentSource(src); - }, - doSceneQuery, - doSceneFragmentScrape, - doMultiSceneFragmentScrape, - stopMultiScrape, - createNewTag, - createNewPerformer, - createNewStudio, - resolveScene, - saveScene, - submitFingerprints, - pendingFingerprints: getPendingFingerprints(), - }} - > - {children} - - ); -}; diff --git a/ui/v2.5/src/components/Scenes/Scraper/modals.tsx b/ui/v2.5/src/components/Scenes/Scraper/modals.tsx deleted file mode 100644 index 0e6dee5e9b8..00000000000 --- a/ui/v2.5/src/components/Scenes/Scraper/modals.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { useState } from "react"; -import * as GQL from "src/core/generated-graphql"; -import PerformerModal from "./PerformerModal"; -import StudioModal from "./StudioModal"; - -type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void; -type StudioModalCallback = (toCreate?: GQL.StudioCreateInput) => void; - -export interface ISceneScraperDialogsContextState { - createPerformerModal: ( - performer: GQL.ScrapedPerformerDataFragment, - callback: (toCreate?: GQL.PerformerCreateInput) => void - ) => void; - createStudioModal: ( - studio: GQL.ScrapedSceneStudioDataFragment, - callback: (toCreate?: GQL.StudioCreateInput) => void - ) => void; -} - -export const SceneScraperDialogsState = React.createContext( - { - createPerformerModal: () => {}, - createStudioModal: () => {}, - } -); - -export const SceneScraperModals: React.FC = ({ children }) => { - 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); - } - - return ( - - {performerToCreate && ( - - )} - {studioToCreate && ( - - )} - {children} - - ); -}; diff --git a/ui/v2.5/src/components/Tagger/Config.tsx b/ui/v2.5/src/components/Tagger/Config.tsx index 9d0b53b8a39..8c8662d768a 100644 --- a/ui/v2.5/src/components/Tagger/Config.tsx +++ b/ui/v2.5/src/components/Tagger/Config.tsx @@ -10,7 +10,7 @@ import { import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "src/components/Shared"; import { ParseMode, TagOperation } from "./constants"; -import { TaggerStateContext } from "./taggerContext"; +import { TaggerStateContext } from "./context"; interface IConfigProps { show: boolean; diff --git a/ui/v2.5/src/components/Tagger/IncludeButton.tsx b/ui/v2.5/src/components/Tagger/IncludeButton.tsx index a4d9d3d11a3..bc0e12ab692 100644 --- a/ui/v2.5/src/components/Tagger/IncludeButton.tsx +++ b/ui/v2.5/src/components/Tagger/IncludeButton.tsx @@ -40,10 +40,7 @@ export const OptionalField: React.FC = ({ }) => { return (
    - + {title && {title}}
    {children}
    diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index c0b3eff6c5b..f9bea355eaa 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -16,7 +16,7 @@ import { uniq } from "lodash"; import { blobToBase64 } from "base64-blob"; import { stringToGender } from "src/utils/gender"; import { OptionalField } from "./IncludeButton"; -import { IScrapedScene, TaggerStateContext } from "./taggerContext"; +import { IScrapedScene, TaggerStateContext } from "./context"; import { OperationButton } from "../Shared/OperationButton"; import { SceneTaggerModalsState } from "./sceneTaggerModals"; import PerformerResult from "./PerformerResult"; diff --git a/ui/v2.5/src/components/Tagger/StudioModal.tsx b/ui/v2.5/src/components/Tagger/StudioModal.tsx index 5911dab6fd6..60e440a1a3c 100644 --- a/ui/v2.5/src/components/Tagger/StudioModal.tsx +++ b/ui/v2.5/src/components/Tagger/StudioModal.tsx @@ -4,7 +4,7 @@ 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 "./taggerContext"; +import { TaggerStateContext } from "./context"; interface IStudioModalProps { studio: GQL.ScrapedSceneStudioDataFragment; diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index 8b4498fc5c3..ae45555c9cd 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -5,7 +5,7 @@ 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 "./taggerContext"; +import { TaggerStateContext } from "./context"; import Config from "./Config"; import { TaggerScene } from "./TaggerScene"; import { SceneTaggerModals } from "./sceneTaggerModals"; diff --git a/ui/v2.5/src/components/Tagger/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/TaggerScene.tsx index 0578363e992..16eb719ab51 100644 --- a/ui/v2.5/src/components/Tagger/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/TaggerScene.tsx @@ -7,7 +7,7 @@ import { FormattedMessage } from "react-intl"; import { sortPerformers } from "src/core/performers"; import { parsePath, prepareQueryString } from "src/components/Tagger/utils"; import { OperationButton } from "src/components/Shared/OperationButton"; -import { TaggerStateContext } from "./taggerContext"; +import { TaggerStateContext } from "./context"; import { ScenePreview } from "../Scenes/SceneCard"; interface ITaggerSceneDetails { diff --git a/ui/v2.5/src/components/Tagger/taggerContext.tsx b/ui/v2.5/src/components/Tagger/context.tsx similarity index 100% rename from ui/v2.5/src/components/Tagger/taggerContext.tsx rename to ui/v2.5/src/components/Tagger/context.tsx diff --git a/ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx b/ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx index 1790fec1852..ad6484064d4 100644 --- a/ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx +++ b/ui/v2.5/src/components/Tagger/sceneTaggerModals.tsx @@ -2,7 +2,7 @@ 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 "./taggerContext"; +import { TaggerStateContext } from "./context"; type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void; type StudioModalCallback = (toCreate?: GQL.StudioCreateInput) => void; From b2c3958ad4c7cf83b4a33863df15a8fd18640db9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 12 Oct 2021 09:23:47 +1100 Subject: [PATCH 07/12] Use config context --- ui/v2.5/src/components/Tagger/context.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 7ecfc14cf89..fddc0f44347 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -12,7 +12,6 @@ import { queryScrapeSceneQuery, queryScrapeSceneQueryFragment, stashBoxSceneBatchQuery, - useConfiguration, useListSceneScrapers, usePerformerCreate, usePerformerUpdate, @@ -22,6 +21,7 @@ import { 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 { @@ -113,7 +113,7 @@ export const TaggerContext: React.FC = ({ children }) => { const stopping = useRef(false); - const stashConfig = useConfiguration(); + const { configuration: stashConfig } = React.useContext(ConfigurationContext); const Scrapers = useListSceneScrapers(); const Toast = useToast(); @@ -125,11 +125,11 @@ export const TaggerContext: React.FC = ({ children }) => { const [updateScene] = useSceneUpdate(); useEffect(() => { - if (!stashConfig.data || !Scrapers.data) { + if (!stashConfig || !Scrapers.data) { return; } - const { stashBoxes } = stashConfig.data.configuration.general; + const { stashBoxes } = stashConfig.general; const scrapers = Scrapers.data.listSceneScrapers; const stashboxSources: ITaggerSource[] = stashBoxes.map((s, i) => ({ @@ -166,7 +166,7 @@ export const TaggerContext: React.FC = ({ children }) => { })); setSources(stashboxSources.concat(scraperSources)); - }, [Scrapers.data, stashConfig.data]); + }, [Scrapers.data, stashConfig]); useEffect(() => { if (sources.length && !currentSource) { From aa5bd48f8244fd2d7d755e541076547eb15540f8 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 12 Oct 2021 09:27:29 +1100 Subject: [PATCH 08/12] Fix stash id setting --- ui/v2.5/src/components/Tagger/StashSearchResult.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index f9bea355eaa..4fd59b2e176 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -270,7 +270,14 @@ const StashSearchResult: React.FC = ({ if (currentSource?.stashboxEndpoint && scene.remote_site_id) { sceneCreateInput.stash_ids = [ - ...(stashScene?.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, From ba8ee4abd3bb664b13ebbac152db5411d5f65eed Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 12 Oct 2021 11:05:48 +1100 Subject: [PATCH 09/12] Improve presentation of unnamed results --- .../components/Tagger/StashSearchResult.tsx | 56 +++++++++++-------- ui/v2.5/src/components/Tagger/styles.scss | 3 +- ui/v2.5/src/locales/en-GB.json | 1 + 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index 4fd59b2e176..399ef8b34aa 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -310,19 +310,6 @@ const StashSearchResult: React.FC = ({ createStudioModal(t, studioModalCallback); } - const sceneTitle = scene.url ? ( - - - - ) : ( - - ); - // constants to get around dot-notation eslint rule const fields = { cover_image: "cover_image", @@ -357,16 +344,39 @@ const StashSearchResult: React.FC = ({ } }; - const renderTitle = () => ( -

    - setExcludedField(fields.title, v)} + const renderTitle = () => { + if (!scene.title) { + return ( +

    + +

    + ); + } + + const sceneTitleEl = scene.url ? ( + - {sceneTitle} -
    -

    - ); + + + ) : ( + + ); + + return ( +

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

    + ); + }; function renderStudioDate() { const text = @@ -532,7 +542,7 @@ const StashSearchResult: React.FC = ({ return ( <>
    -
    +
    {maybeRenderCoverImage()}
    {renderTitle()} diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 3a4d10cbd86..7245ffc10bd 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -76,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 { diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index faac661aef1..0a53bda52a2 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -134,6 +134,7 @@ "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" }, From 158a1914451f666f2d05e9d5a4f00cc5116b2346 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 12 Oct 2021 13:11:26 +1100 Subject: [PATCH 10/12] Allow stash id field selection --- .../components/Tagger/StashSearchResult.tsx | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index 399ef8b34aa..177b8d73c24 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -213,6 +213,14 @@ const StashSearchResult: React.FC = ({ } }, [isActive, loading, stashScene, index, resolveScene, scene]); + 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({ ...excludedFields, @@ -268,7 +276,13 @@ const StashSearchResult: React.FC = ({ stash_ids: stashScene.stash_ids ?? [], }; - if (currentSource?.stashboxEndpoint && scene.remote_site_id) { + const includeStashID = !excludedFieldList.includes("stash_ids"); + + if ( + includeStashID && + currentSource?.stashboxEndpoint && + scene.remote_site_id + ) { sceneCreateInput.stash_ids = [ ...(stashScene?.stash_ids .map((s) => { @@ -285,7 +299,7 @@ const StashSearchResult: React.FC = ({ ]; } - await saveScene(sceneCreateInput); + await saveScene(sceneCreateInput, includeStashID); } function performerModalCallback( @@ -318,6 +332,7 @@ const StashSearchResult: React.FC = ({ url: "url", details: "details", studio: "studio", + stash_ids: "stash_ids", }; const maybeRenderCoverImage = () => { @@ -331,13 +346,11 @@ const StashSearchResult: React.FC = ({ } setExclude={(v) => setExcludedField(fields.cover_image, v)} > - - - +
    ); @@ -450,6 +463,23 @@ const StashSearchResult: React.FC = ({ } }; + const maybeRenderStashBoxID = () => { + if (scene.remote_site_id && stashBoxURL) { + return ( +
    + setExcludedField(fields.stash_ids, v)} + > + + {scene.remote_site_id} + + +
    + ); + } + }; + const maybeRenderStudioField = () => { if (scene.studio) { return ( @@ -561,6 +591,7 @@ const StashSearchResult: React.FC = ({
    {isActive && (
    + {maybeRenderStashBoxID()} {maybeRenderURL()} {maybeRenderDetails()}
    From f4e126fb87eb920509ae76ce05ad8b8c9860c921 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 12 Oct 2021 13:11:46 +1100 Subject: [PATCH 11/12] Fix error handling --- ui/v2.5/src/components/Tagger/context.tsx | 18 ++++++++++++++--- ui/v2.5/src/hooks/Toast.tsx | 24 +++++++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index fddc0f44347..86fb17443e4 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -57,7 +57,10 @@ export interface ITaggerContextState { ) => Promise; submitFingerprints: () => Promise; pendingFingerprints: string[]; - saveScene: (sceneCreateInput: GQL.SceneUpdateInput) => Promise; + saveScene: ( + sceneCreateInput: GQL.SceneUpdateInput, + queueFingerprint: boolean + ) => Promise; } const dummyFn = () => { @@ -319,6 +322,8 @@ export const TaggerContext: React.FC = ({ children }) => { try { setLoading(true); await sceneFragmentScrape(sceneID); + } catch (err) { + Toast.error(err); } finally { setLoading(false); } @@ -376,6 +381,8 @@ export const TaggerContext: React.FC = ({ children }) => { } }, Promise.resolve()); } + } catch (err) { + Toast.error(err); } finally { setLoading(false); setLoadingMulti(false); @@ -440,7 +447,10 @@ export const TaggerContext: React.FC = ({ children }) => { }); } - async function saveScene(sceneCreateInput: GQL.SceneUpdateInput) { + async function saveScene( + sceneCreateInput: GQL.SceneUpdateInput, + queueFingerprint: boolean + ) { try { await updateScene({ variables: { @@ -448,7 +458,9 @@ export const TaggerContext: React.FC = ({ children }) => { }, }); - queueFingerprintSubmission(sceneCreateInput.id); + if (queueFingerprint) { + queueFingerprintSubmission(sceneCreateInput.id); + } clearSearchResults(sceneCreateInput.id); } catch (err) { Toast.error(err); diff --git a/ui/v2.5/src/hooks/Toast.tsx b/ui/v2.5/src/hooks/Toast.tsx index b8e94da71ac..5489fa4059d 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 */ }, }; } From b9ef4c2aaf8629d06deefaaefa177bb90d1d6a6a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 14 Oct 2021 08:31:45 +1100 Subject: [PATCH 12/12] Add changelog entry --- ui/v2.5/src/components/Changelog/versions/v0110.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/v2.5/src/components/Changelog/versions/v0110.md b/ui/v2.5/src/components/Changelog/versions/v0110.md index d6b8f5c6761..5cee669d02b 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))