Add studio image scraping feature

Implements comprehensive studio image scraping with:
- StashBox integration via StudioStashBoxModal component
- URL-based web scraping via StudioScrapeDialog component
- Scraper menu integration in studio editor
- Basic English localization (en-GB)

This feature allows users to import studio images from:
1. StashBox search by studio name
2. Direct URL scraping via configured scrapers

UI components include modal dialogs for search results and
scraper output with image preview and import functionality.
This commit is contained in:
RyanAtNight 2026-01-01 15:52:08 -08:00
parent 9b709ef614
commit 3e5ac408fa
4 changed files with 567 additions and 20 deletions

View file

@ -1,14 +1,16 @@
import React, { useEffect, useState } from "react";
import { useIntl } from "react-intl";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import Mousetrap from "mousetrap";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { Button, Form } from "react-bootstrap";
import { ImageInput } from "src/components/Shared/ImageInput";
import cx from "classnames";
import { Button, Dropdown, Form } from "react-bootstrap";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import ImageUtils from "src/utils/image";
import { addUpdateStashID, getStashIDs } from "src/utils/stashIds";
import { stashboxDisplayName } from "src/utils/stashbox";
import { useFormik } from "formik";
import { Prompt } from "react-router-dom";
import isEqual from "lodash-es/isEqual";
@ -21,6 +23,8 @@ import { Studio, StudioSelect } from "../StudioSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { Icon } from "src/components/Shared/Icon";
import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal";
import StudioStashBoxModal, { IStashBox } from "./StudioStashBoxModal";
import { StudioScrapeDialog } from "./StudioScrapeDialog";
interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>;
@ -45,7 +49,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
const isNew = studio.id === undefined;
// Editing state
// Editing/scraper state
const [scraper, setScraper] = useState<IStashBox>();
const [isScraperModalOpen, setIsScraperModalOpen] = useState(false);
const [scrapedStudio, setScrapedStudio] = useState<GQL.ScrapedStudio>();
const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false);
// Network state
@ -86,8 +93,9 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
onSubmit: (values) => onSave(schema.cast(values)),
});
const { tagsControl } = useTagsEdit(studio.tags, (ids) =>
formik.setFieldValue("tag_ids", ids)
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
studio.tags,
(ids) => formik.setFieldValue("tag_ids", ids)
);
function onSetParentStudio(item: Studio | null) {
@ -159,6 +167,189 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
);
}
function updateStashIDs(remoteSiteID: string | null | undefined) {
if (remoteSiteID && scraper?.endpoint) {
const newIDs =
formik.values.stash_ids?.filter(
(s) => s.endpoint !== scraper.endpoint
) ?? [];
newIDs.push({
endpoint: scraper.endpoint,
stash_id: remoteSiteID,
updated_at: new Date().toISOString(),
});
formik.setFieldValue("stash_ids", newIDs);
}
}
function updateStudioEditStateFromScraper(
state: Partial<GQL.ScrapedStudioDataFragment>
) {
if (state.name) {
formik.setFieldValue("name", state.name);
}
if (state.urls) {
formik.setFieldValue("urls", state.urls);
}
if (state.details) {
formik.setFieldValue("details", state.details);
}
if (state.aliases) {
formik.setFieldValue(
"aliases",
state.aliases.split(",").map((a) => a.trim())
);
}
updateTagsStateFromScraper(state.tags ?? undefined);
// image is a base64 string
// overwrite if not new since it came from a dialog
// overwrite if image is unset
if ((!isNew || !formik.values.image) && state.image) {
formik.setFieldValue("image", state.image);
}
updateStashIDs(state.remote_site_id);
}
function onScrapeStashBox(studioResult: GQL.ScrapedStudio) {
setIsScraperModalOpen(false);
const result: GQL.ScrapedStudioDataFragment = {
...studioResult,
__typename: "ScrapedStudio",
};
// if this is a new studio, just dump the data
if (isNew) {
updateStudioEditStateFromScraper(result);
setScraper(undefined);
} else {
setScrapedStudio(result);
}
}
function onScraperSelected(s: IStashBox) {
setScraper(s);
setIsScraperModalOpen(true);
}
function renderScraperMenu() {
if (!studio) {
return;
}
const stashBoxes = stashConfig?.general.stashBoxes ?? [];
if (stashBoxes.length === 0) {
return;
}
const popover = (
<Dropdown.Menu id="studio-scraper-popover">
{stashBoxes.map((s, index) => (
<Dropdown.Item
as={Button}
key={s.endpoint}
className="minimal"
onClick={() => onScraperSelected({ ...s, index })}
>
{stashboxDisplayName(s.name, index)}
</Dropdown.Item>
))}
</Dropdown.Menu>
);
return (
<Dropdown className="d-inline-block">
<Dropdown.Toggle variant="secondary" className="mr-2">
<FormattedMessage id="actions.scrape_with" />
</Dropdown.Toggle>
{popover}
</Dropdown>
);
}
function renderButtons(classNames: string) {
return (
<div className={cx("details-edit", "col-xl-9", classNames)}>
{!isNew && (
<Button className="mr-2" variant="primary" onClick={onCancel}>
<FormattedMessage id="actions.cancel" />
</Button>
)}
{renderScraperMenu()}
<ImageInput
isEditing
onImageChange={onImageChange}
onImageURL={onImageLoad}
acceptSVG
/>
<div>
<Button
className="mr-2"
variant="danger"
onClick={() => onImageLoad(null)}
>
<FormattedMessage id="actions.clear_image" />
</Button>
</div>
<Button
variant="success"
disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
</div>
);
}
function maybeRenderScrapeDialog() {
if (!scrapedStudio || !scraper) {
return;
}
const currentStudio = {
...formik.values,
image: formik.values.image ?? studio.image_path,
};
return (
<StudioScrapeDialog
studio={currentStudio}
studioTags={tags}
scraped={scrapedStudio}
scraper={scraper}
onClose={(s) => {
onScrapeDialogClosed(s);
}}
/>
);
}
function onScrapeDialogClosed(s?: GQL.ScrapedStudioDataFragment) {
if (s) {
updateStudioEditStateFromScraper(s);
}
setScrapedStudio(undefined);
setScraper(undefined);
}
function renderScrapeModal() {
if (!isScraperModalOpen || !scraper) {
return;
}
return (
<StudioStashBoxModal
instance={scraper}
onHide={() => setScraper(undefined)}
onSelectStudio={onScrapeStashBox}
name={formik.values.name || ""}
/>
);
}
const {
renderField,
renderInputField,
@ -189,6 +380,8 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
return (
<>
{renderScrapeModal()}
{maybeRenderScrapeDialog()}
{isStashIDSearchOpen && (
<StashBoxIDSearchModal
entityType="studio"
@ -241,20 +434,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
{renderInputField("ignore_auto_tag", "checkbox")}
</Form>
<DetailsEditNavbar
objectName={studio?.name ?? intl.formatMessage({ id: "studio" })}
classNames="col-xl-9 mt-3"
isNew={isNew}
isEditing
onToggleEdit={onCancel}
onSave={formik.handleSubmit}
saveDisabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
onImageChange={onImageChange}
onImageChangeURL={onImageLoad}
onClearImage={() => onImageLoad(null)}
onDelete={onDelete}
acceptSVG
/>
{renderButtons("mt-3")}
</>
);
};

View file

@ -0,0 +1,182 @@
import React, { useState } from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import {
ScrapedInputGroupRow,
ScrapedImageRow,
ScrapedTextAreaRow,
ScrapedStringListRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialogRow";
import { ScrapeDialog } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import { IStashBox } from "./StudioStashBoxModal";
import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult";
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
import { Tag } from "src/components/Tags/TagSelect";
import { uniq } from "lodash-es";
interface IStudioScrapeDialogProps {
studio: Partial<GQL.StudioUpdateInput>;
studioTags: Tag[];
scraped: GQL.ScrapedStudio;
scraper: IStashBox;
onClose: (scrapedStudio?: GQL.ScrapedStudio) => void;
}
export const StudioScrapeDialog: React.FC<IStudioScrapeDialogProps> = ({
studio,
studioTags,
scraped,
scraper,
onClose,
}) => {
const intl = useIntl();
const { endpoint } = scraper;
function getCurrentRemoteSiteID() {
if (!endpoint) {
return;
}
const stashIDs = (studio.stash_ids ?? []).filter(
(s) => s.endpoint === endpoint
);
if (stashIDs.length > 1 && scraped.remote_site_id) {
const matchingID = stashIDs.find(
(s) => s.stash_id === scraped.remote_site_id
);
if (matchingID) {
return matchingID.stash_id;
}
}
return studio.stash_ids?.find((s) => s.endpoint === endpoint)?.stash_id;
}
const [name, setName] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(studio.name, scraped.name)
);
const [urls, setURLs] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
studio.urls,
scraped.urls
? uniq((studio.urls ?? []).concat(scraped.urls ?? []))
: undefined
)
);
const [details, setDetails] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(studio.details, scraped.details)
);
const [aliases, setAliases] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
studio.aliases?.join(", "),
scraped.aliases
)
);
const [remoteSiteID, setRemoteSiteID] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(
getCurrentRemoteSiteID(),
scraped.remote_site_id
)
);
const { tags, newTags, scrapedTagsRow, linkDialog } = useScrapedTags(
studioTags,
scraped.tags,
endpoint
);
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(studio.image, scraped.image)
);
const allFields = [name, urls, details, aliases, tags, image, remoteSiteID];
// don't show the dialog if nothing was scraped
if (allFields.every((r) => !r.scraped) && newTags.length === 0) {
onClose();
return <></>;
}
function makeNewScrapedItem(): GQL.ScrapedStudio {
return {
name: name.getNewValue() ?? "",
urls: urls.getNewValue(),
details: details.getNewValue(),
aliases: aliases.getNewValue(),
tags: tags.getNewValue(),
image: image.getNewValue(),
remote_site_id: remoteSiteID.getNewValue(),
// Include parent from original scraped data (read-only)
parent: scraped.parent,
};
}
function renderScrapeRows() {
return (
<>
<ScrapedInputGroupRow
field="name"
title={intl.formatMessage({ id: "name" })}
result={name}
onChange={(value) => setName(value)}
/>
<ScrapedStringListRow
field="urls"
title={intl.formatMessage({ id: "urls" })}
result={urls}
onChange={(value) => setURLs(value)}
/>
<ScrapedTextAreaRow
field="details"
title={intl.formatMessage({ id: "details" })}
result={details}
onChange={(value) => setDetails(value)}
/>
<ScrapedTextAreaRow
field="aliases"
title={intl.formatMessage({ id: "aliases" })}
result={aliases}
onChange={(value) => setAliases(value)}
/>
{scrapedTagsRow}
<ScrapedImageRow
field="image"
title={intl.formatMessage({ id: "studio_image" })}
className="studio-image"
result={image}
onChange={(value) => setImage(value)}
/>
<ScrapedInputGroupRow
field="remote_site_id"
title={intl.formatMessage({ id: "stash_id" })}
result={remoteSiteID}
locked
onChange={(value) => setRemoteSiteID(value)}
/>
</>
);
}
if (linkDialog) {
return linkDialog;
}
return (
<ScrapeDialog
title={intl.formatMessage(
{ id: "dialogs.scrape_entity_title" },
{ entity_type: intl.formatMessage({ id: "studio" }) }
)}
onClose={(apply) => {
onClose(apply ? makeNewScrapedItem() : undefined);
}}
>
{renderScrapeRows()}
</ScrapeDialog>
);
};

View file

@ -0,0 +1,183 @@
import React, { useEffect, useRef, useState } from "react";
import { Form, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { ModalComponent } from "src/components/Shared/Modal";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { stashboxDisplayName } from "src/utils/stashbox";
import { useDebounce } from "src/hooks/debounce";
import { TruncatedText } from "src/components/Shared/TruncatedText";
const CLASSNAME = "StudioScrapeModal";
const CLASSNAME_LIST = `${CLASSNAME}-list`;
const CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`;
interface IStudioSearchResultDetailsProps {
studio: GQL.ScrapedStudioDataFragment;
}
const StudioSearchResultDetails: React.FC<IStudioSearchResultDetailsProps> = ({
studio,
}) => {
function renderImage() {
if (studio.image) {
return (
<div className="scene-image-container">
<img
src={studio.image}
alt=""
className="align-self-center scene-image"
/>
</div>
);
}
}
return (
<div className="studio-result">
<Row>
{renderImage()}
<div className="col flex-column">
<h4 className="studio-name">
<span>{studio.name}</span>
</h4>
{studio.parent?.name && (
<h5 className="studio-parent text-muted">
<span>{studio.parent.name}</span>
</h5>
)}
{studio.urls && studio.urls.length > 0 && (
<div className="studio-url text-muted small">{studio.urls[0]}</div>
)}
</div>
</Row>
{studio.details && (
<Row>
<div className="col">
<TruncatedText text={studio.details} lineCount={3} />
</div>
</Row>
)}
</div>
);
};
export interface IStudioSearchResult {
studio: GQL.ScrapedStudioDataFragment;
}
export const StudioSearchResult: React.FC<IStudioSearchResult> = ({
studio,
}) => {
return (
<div className="mt-3 search-item">
<StudioSearchResultDetails studio={studio} />
</div>
);
};
export interface IStashBox extends GQL.StashBox {
index: number;
}
interface IProps {
instance: IStashBox;
onHide: () => void;
onSelectStudio: (studio: GQL.ScrapedStudio) => void;
name?: string;
}
const StudioStashBoxModal: React.FC<IProps> = ({
instance,
name,
onHide,
onSelectStudio,
}) => {
const intl = useIntl();
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState<string>(name ?? "");
const { data, loading } = GQL.useScrapeSingleStudioQuery({
variables: {
source: {
stash_box_endpoint: instance.endpoint,
},
input: {
query,
},
},
skip: query === "",
});
const studios = data?.scrapeSingleStudio ?? [];
const onInputChange = useDebounce(setQuery, 500);
useEffect(() => inputRef.current?.focus(), []);
function renderResults() {
if (!studios) {
return;
}
return (
<div className={CLASSNAME_LIST_CONTAINER}>
<div className="mt-1">
<FormattedMessage
id="dialogs.studios_found"
values={{ count: studios.length }}
/>
</div>
<ul className={CLASSNAME_LIST}>
{studios.map((s, i) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key
<li key={i} onClick={() => onSelectStudio(s)}>
<StudioSearchResult studio={s} />
</li>
))}
</ul>
</div>
);
}
return (
<ModalComponent
show
onHide={onHide}
header={`Scrape studio from ${stashboxDisplayName(
instance.name,
instance.index
)}`}
accept={{
text: intl.formatMessage({ id: "actions.cancel" }),
onClick: onHide,
variant: "secondary",
}}
>
<div className={CLASSNAME}>
<Form.Control
onChange={(e) => onInputChange(e.currentTarget.value)}
defaultValue={name ?? ""}
placeholder={intl.formatMessage({ id: "studio_name" }) + "..."}
className="text-input mb-4"
ref={inputRef}
/>
{loading ? (
<div className="m-4 text-center">
<LoadingIndicator inline />
</div>
) : studios.length > 0 ? (
renderResults()
) : (
query !== "" && (
<h5 className="text-center">
<FormattedMessage id="stashbox_search.no_results" />
</h5>
)
)}
</div>
</ModalComponent>
);
};
export default StudioStashBoxModal;

View file

@ -1491,6 +1491,8 @@
"studio_and_parent": "Studio & Parent",
"studio_count": "Studio Count",
"studio_depth": "Levels (empty for all)",
"studio_image": "Studio Image",
"studio_name": "Studio name",
"studio_tagger": {
"add_new_studios": "Add New Studios",
"any_names_entered_will_be_queried": "Any names entered will be queried from the remote Stash-Box instance and added if found. Only exact matches will be considered a match.",