mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Add gallery scraping (#862)
This commit is contained in:
@@ -2,16 +2,23 @@ import React, { useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Button, Form, Col, Row } from "react-bootstrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useGalleryCreate, useGalleryUpdate } from "src/core/StashService";
|
||||
import {
|
||||
queryScrapeGalleryURL,
|
||||
useGalleryCreate,
|
||||
useGalleryUpdate,
|
||||
useListGalleryScrapers,
|
||||
} from "src/core/StashService";
|
||||
import {
|
||||
PerformerSelect,
|
||||
TagSelect,
|
||||
StudioSelect,
|
||||
Icon,
|
||||
LoadingIndicator,
|
||||
} from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import { FormUtils, EditableTextUtils } from "src/utils";
|
||||
import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars";
|
||||
import { GalleryScrapeDialog } from "./GalleryScrapeDialog";
|
||||
|
||||
interface IProps {
|
||||
isVisible: boolean;
|
||||
@@ -42,6 +49,13 @@ export const GalleryEditPanel: React.FC<
|
||||
const [performerIds, setPerformerIds] = useState<string[]>();
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
|
||||
const Scrapers = useListGalleryScrapers();
|
||||
|
||||
const [
|
||||
scrapedGallery,
|
||||
setScrapedGallery,
|
||||
] = useState<GQL.ScrapedGallery | null>();
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -148,10 +162,121 @@ export const GalleryEditPanel: React.FC<
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
function onScrapeDialogClosed(gallery?: GQL.ScrapedGalleryDataFragment) {
|
||||
if (gallery) {
|
||||
updateGalleryFromScrapedGallery(gallery);
|
||||
}
|
||||
setScrapedGallery(undefined);
|
||||
}
|
||||
|
||||
function maybeRenderScrapeDialog() {
|
||||
if (!scrapedGallery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentGallery = getGalleryInput();
|
||||
|
||||
return (
|
||||
<GalleryScrapeDialog
|
||||
gallery={currentGallery}
|
||||
scraped={scrapedGallery}
|
||||
onClose={(gallery) => {
|
||||
onScrapeDialogClosed(gallery);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function urlScrapable(scrapedUrl: string): boolean {
|
||||
return (Scrapers?.data?.listGalleryScrapers ?? []).some((s) =>
|
||||
(s?.gallery?.urls ?? []).some((u) => scrapedUrl.includes(u))
|
||||
);
|
||||
}
|
||||
|
||||
function updateGalleryFromScrapedGallery(
|
||||
gallery: GQL.ScrapedGalleryDataFragment
|
||||
) {
|
||||
if (gallery.title) {
|
||||
setTitle(gallery.title);
|
||||
}
|
||||
|
||||
if (gallery.details) {
|
||||
setDetails(gallery.details);
|
||||
}
|
||||
|
||||
if (gallery.date) {
|
||||
setDate(gallery.date);
|
||||
}
|
||||
|
||||
if (gallery.url) {
|
||||
setUrl(gallery.url);
|
||||
}
|
||||
|
||||
if (gallery.studio && gallery.studio.stored_id) {
|
||||
setStudioId(gallery.studio.stored_id);
|
||||
}
|
||||
|
||||
if (gallery.performers && gallery.performers.length > 0) {
|
||||
const idPerfs = gallery.performers.filter((p) => {
|
||||
return p.stored_id !== undefined && p.stored_id !== null;
|
||||
});
|
||||
|
||||
if (idPerfs.length > 0) {
|
||||
const newIds = idPerfs.map((p) => p.stored_id);
|
||||
setPerformerIds(newIds as string[]);
|
||||
}
|
||||
}
|
||||
|
||||
if (gallery?.tags?.length) {
|
||||
const idTags = gallery.tags.filter((p) => {
|
||||
return p.stored_id !== undefined && p.stored_id !== null;
|
||||
});
|
||||
|
||||
if (idTags.length > 0) {
|
||||
const newIds = idTags.map((p) => p.stored_id);
|
||||
setTagIds(newIds as string[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onScrapeGalleryURL() {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapeGalleryURL(url);
|
||||
if (!result || !result.data || !result.data.scrapeGalleryURL) {
|
||||
return;
|
||||
}
|
||||
setScrapedGallery(result.data.scrapeGalleryURL);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderScrapeButton() {
|
||||
if (!url || !urlScrapable(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
className="minimal scrape-url-button"
|
||||
onClick={onScrapeGalleryURL}
|
||||
title="Scrape"
|
||||
>
|
||||
<Icon className="fa-fw" icon="file-download" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <LoadingIndicator />;
|
||||
|
||||
return (
|
||||
<div id="gallery-edit-details">
|
||||
{maybeRenderScrapeDialog()}
|
||||
<div className="form-container row px-3 pt-3">
|
||||
<div className="col edit-buttons mb-3 pl-0">
|
||||
<Button className="edit-button" variant="primary" onClick={onSave}>
|
||||
@@ -177,6 +302,9 @@ export const GalleryEditPanel: React.FC<
|
||||
<Form.Group controlId="url" as={Row}>
|
||||
<Col xs={3} className="pr-0 url-label">
|
||||
<Form.Label className="col-form-label">URL</Form.Label>
|
||||
<div className="float-right scrape-button-container">
|
||||
{maybeRenderScrapeButton()}
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={9}>
|
||||
{EditableTextUtils.renderInputGroup({
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { useState } from "react";
|
||||
import { StudioSelect, PerformerSelect } from "src/components/Shared";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { TagSelect } from "src/components/Shared/Select";
|
||||
import {
|
||||
ScrapeDialog,
|
||||
ScrapeDialogRow,
|
||||
ScrapeResult,
|
||||
ScrapedInputGroupRow,
|
||||
ScrapedTextAreaRow,
|
||||
} from "src/components/Shared/ScrapeDialog";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
useStudioCreate,
|
||||
usePerformerCreate,
|
||||
useTagCreate,
|
||||
} from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
|
||||
function renderScrapedStudio(
|
||||
result: ScrapeResult<string>,
|
||||
isNew?: boolean,
|
||||
onChange?: (value: string) => void
|
||||
) {
|
||||
const resultValue = isNew ? result.newValue : result.originalValue;
|
||||
const value = resultValue ? [resultValue] : [];
|
||||
|
||||
return (
|
||||
<StudioSelect
|
||||
className="form-control react-select"
|
||||
isDisabled={!isNew}
|
||||
onSelect={(items) => {
|
||||
if (onChange) {
|
||||
onChange(items[0]?.id);
|
||||
}
|
||||
}}
|
||||
ids={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScrapedStudioRow(
|
||||
result: ScrapeResult<string>,
|
||||
onChange: (value: ScrapeResult<string>) => void,
|
||||
newStudio?: GQL.ScrapedSceneStudio,
|
||||
onCreateNew?: (value: GQL.ScrapedSceneStudio) => void
|
||||
) {
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title="Studio"
|
||||
result={result}
|
||||
renderOriginalField={() => renderScrapedStudio(result)}
|
||||
renderNewField={() =>
|
||||
renderScrapedStudio(result, true, (value) =>
|
||||
onChange(result.cloneWithValue(value))
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
newValues={newStudio ? [newStudio] : undefined}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScrapedPerformers(
|
||||
result: ScrapeResult<string[]>,
|
||||
isNew?: boolean,
|
||||
onChange?: (value: string[]) => void
|
||||
) {
|
||||
const resultValue = isNew ? result.newValue : result.originalValue;
|
||||
const value = resultValue ?? [];
|
||||
|
||||
return (
|
||||
<PerformerSelect
|
||||
isMulti
|
||||
className="form-control react-select"
|
||||
isDisabled={!isNew}
|
||||
onSelect={(items) => {
|
||||
if (onChange) {
|
||||
onChange(items.map((i) => i.id));
|
||||
}
|
||||
}}
|
||||
ids={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScrapedPerformersRow(
|
||||
result: ScrapeResult<string[]>,
|
||||
onChange: (value: ScrapeResult<string[]>) => void,
|
||||
newPerformers: GQL.ScrapedScenePerformer[],
|
||||
onCreateNew?: (value: GQL.ScrapedScenePerformer) => void
|
||||
) {
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title="Performers"
|
||||
result={result}
|
||||
renderOriginalField={() => renderScrapedPerformers(result)}
|
||||
renderNewField={() =>
|
||||
renderScrapedPerformers(result, true, (value) =>
|
||||
onChange(result.cloneWithValue(value))
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
newValues={newPerformers}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScrapedTags(
|
||||
result: ScrapeResult<string[]>,
|
||||
isNew?: boolean,
|
||||
onChange?: (value: string[]) => void
|
||||
) {
|
||||
const resultValue = isNew ? result.newValue : result.originalValue;
|
||||
const value = resultValue ?? [];
|
||||
|
||||
return (
|
||||
<TagSelect
|
||||
isMulti
|
||||
className="form-control react-select"
|
||||
isDisabled={!isNew}
|
||||
onSelect={(items) => {
|
||||
if (onChange) {
|
||||
onChange(items.map((i) => i.id));
|
||||
}
|
||||
}}
|
||||
ids={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScrapedTagsRow(
|
||||
result: ScrapeResult<string[]>,
|
||||
onChange: (value: ScrapeResult<string[]>) => void,
|
||||
newTags: GQL.ScrapedSceneTag[],
|
||||
onCreateNew?: (value: GQL.ScrapedSceneTag) => void
|
||||
) {
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title="Tags"
|
||||
result={result}
|
||||
renderOriginalField={() => renderScrapedTags(result)}
|
||||
renderNewField={() =>
|
||||
renderScrapedTags(result, true, (value) =>
|
||||
onChange(result.cloneWithValue(value))
|
||||
)
|
||||
}
|
||||
newValues={newTags}
|
||||
onChange={onChange}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface IGalleryScrapeDialogProps {
|
||||
gallery: Partial<GQL.GalleryUpdateInput>;
|
||||
scraped: GQL.ScrapedGallery;
|
||||
|
||||
onClose: (scrapedGallery?: GQL.ScrapedGallery) => void;
|
||||
}
|
||||
|
||||
interface IHasStoredID {
|
||||
stored_id?: string | null;
|
||||
}
|
||||
|
||||
export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = (
|
||||
props: IGalleryScrapeDialogProps
|
||||
) => {
|
||||
const [title, setTitle] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.gallery.title, props.scraped.title)
|
||||
);
|
||||
const [url, setURL] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.gallery.url, props.scraped.url)
|
||||
);
|
||||
const [date, setDate] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.gallery.date, props.scraped.date)
|
||||
);
|
||||
const [studio, setStudio] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(
|
||||
props.gallery.studio_id,
|
||||
props.scraped.studio?.stored_id
|
||||
)
|
||||
);
|
||||
const [newStudio, setNewStudio] = useState<
|
||||
GQL.ScrapedSceneStudio | undefined
|
||||
>(
|
||||
props.scraped.studio && !props.scraped.studio.stored_id
|
||||
? props.scraped.studio
|
||||
: undefined
|
||||
);
|
||||
|
||||
function mapStoredIdObjects(
|
||||
scrapedObjects?: IHasStoredID[]
|
||||
): string[] | undefined {
|
||||
if (!scrapedObjects) {
|
||||
return undefined;
|
||||
}
|
||||
const ret = scrapedObjects
|
||||
.map((p) => p.stored_id)
|
||||
.filter((p) => {
|
||||
return p !== undefined && p !== null;
|
||||
}) as string[];
|
||||
|
||||
if (ret.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// sort by id numerically
|
||||
ret.sort((a, b) => {
|
||||
return parseInt(a, 10) - parseInt(b, 10);
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function sortIdList(idList?: string[] | null) {
|
||||
if (!idList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ret = _.clone(idList);
|
||||
// sort by id numerically
|
||||
ret.sort((a, b) => {
|
||||
return parseInt(a, 10) - parseInt(b, 10);
|
||||
});
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
const [performers, setPerformers] = useState<ScrapeResult<string[]>>(
|
||||
new ScrapeResult<string[]>(
|
||||
sortIdList(props.gallery.performer_ids),
|
||||
mapStoredIdObjects(props.scraped.performers ?? undefined)
|
||||
)
|
||||
);
|
||||
const [newPerformers, setNewPerformers] = useState<
|
||||
GQL.ScrapedScenePerformer[]
|
||||
>(props.scraped.performers?.filter((t) => !t.stored_id) ?? []);
|
||||
|
||||
const [tags, setTags] = useState<ScrapeResult<string[]>>(
|
||||
new ScrapeResult<string[]>(
|
||||
sortIdList(props.gallery.tag_ids),
|
||||
mapStoredIdObjects(props.scraped.tags ?? undefined)
|
||||
)
|
||||
);
|
||||
const [newTags, setNewTags] = useState<GQL.ScrapedSceneTag[]>(
|
||||
props.scraped.tags?.filter((t) => !t.stored_id) ?? []
|
||||
);
|
||||
|
||||
const [details, setDetails] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.gallery.details, props.scraped.details)
|
||||
);
|
||||
|
||||
const [createStudio] = useStudioCreate({ name: "" });
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
const [createTag] = useTagCreate({ name: "" });
|
||||
|
||||
const Toast = useToast();
|
||||
|
||||
// don't show the dialog if nothing was scraped
|
||||
if (
|
||||
[title, url, date, studio, performers, tags, details].every(
|
||||
(r) => !r.scraped
|
||||
)
|
||||
) {
|
||||
props.onClose();
|
||||
return <></>;
|
||||
}
|
||||
|
||||
async function createNewStudio(toCreate: GQL.ScrapedSceneStudio) {
|
||||
try {
|
||||
const result = await createStudio({
|
||||
variables: {
|
||||
name: toCreate.name,
|
||||
url: toCreate.url,
|
||||
},
|
||||
});
|
||||
|
||||
// set the new studio as the value
|
||||
setStudio(studio.cloneWithValue(result.data!.studioCreate!.id));
|
||||
setNewStudio(undefined);
|
||||
|
||||
Toast.success({
|
||||
content: (
|
||||
<span>
|
||||
Created studio: <b>{toCreate.name}</b>
|
||||
</span>
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewPerformer(toCreate: GQL.ScrapedScenePerformer) {
|
||||
let performerInput: GQL.PerformerCreateInput = {};
|
||||
try {
|
||||
performerInput = Object.assign(performerInput, toCreate);
|
||||
const result = await createPerformer({
|
||||
variables: performerInput,
|
||||
});
|
||||
|
||||
// add the new performer to the new performers value
|
||||
const performerClone = performers.cloneWithValue(performers.newValue);
|
||||
if (!performerClone.newValue) {
|
||||
performerClone.newValue = [];
|
||||
}
|
||||
performerClone.newValue.push(result.data!.performerCreate!.id);
|
||||
setPerformers(performerClone);
|
||||
|
||||
// remove the performer from the list
|
||||
const newPerformersClone = newPerformers.concat();
|
||||
const pIndex = newPerformersClone.indexOf(toCreate);
|
||||
newPerformersClone.splice(pIndex, 1);
|
||||
|
||||
setNewPerformers(newPerformersClone);
|
||||
|
||||
Toast.success({
|
||||
content: (
|
||||
<span>
|
||||
Created performer: <b>{toCreate.name}</b>
|
||||
</span>
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewTag(toCreate: GQL.ScrapedSceneTag) {
|
||||
let tagInput: GQL.TagCreateInput = { name: "" };
|
||||
try {
|
||||
tagInput = Object.assign(tagInput, toCreate);
|
||||
const result = await createTag({
|
||||
variables: tagInput,
|
||||
});
|
||||
|
||||
// add the new tag to the new tags value
|
||||
const tagClone = tags.cloneWithValue(tags.newValue);
|
||||
if (!tagClone.newValue) {
|
||||
tagClone.newValue = [];
|
||||
}
|
||||
tagClone.newValue.push(result.data!.tagCreate!.id);
|
||||
setTags(tagClone);
|
||||
|
||||
// remove the tag from the list
|
||||
const newTagsClone = newTags.concat();
|
||||
const pIndex = newTagsClone.indexOf(toCreate);
|
||||
newTagsClone.splice(pIndex, 1);
|
||||
|
||||
setNewTags(newTagsClone);
|
||||
|
||||
Toast.success({
|
||||
content: (
|
||||
<span>
|
||||
Created tag: <b>{toCreate.name}</b>
|
||||
</span>
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function makeNewScrapedItem(): GQL.ScrapedGalleryDataFragment {
|
||||
const newStudioValue = studio.getNewValue();
|
||||
|
||||
return {
|
||||
title: title.getNewValue(),
|
||||
url: url.getNewValue(),
|
||||
date: date.getNewValue(),
|
||||
studio: newStudioValue
|
||||
? {
|
||||
stored_id: newStudioValue,
|
||||
name: "",
|
||||
}
|
||||
: undefined,
|
||||
performers: performers.getNewValue()?.map((p) => {
|
||||
return {
|
||||
stored_id: p,
|
||||
name: "",
|
||||
};
|
||||
}),
|
||||
tags: tags.getNewValue()?.map((m) => {
|
||||
return {
|
||||
stored_id: m,
|
||||
name: "",
|
||||
};
|
||||
}),
|
||||
details: details.getNewValue(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderScrapeRows() {
|
||||
return (
|
||||
<>
|
||||
<ScrapedInputGroupRow
|
||||
title="Title"
|
||||
result={title}
|
||||
onChange={(value) => setTitle(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title="URL"
|
||||
result={url}
|
||||
onChange={(value) => setURL(value)}
|
||||
/>
|
||||
<ScrapedInputGroupRow
|
||||
title="Date"
|
||||
placeholder="YYYY-MM-DD"
|
||||
result={date}
|
||||
onChange={(value) => setDate(value)}
|
||||
/>
|
||||
{renderScrapedStudioRow(
|
||||
studio,
|
||||
(value) => setStudio(value),
|
||||
newStudio,
|
||||
createNewStudio
|
||||
)}
|
||||
{renderScrapedPerformersRow(
|
||||
performers,
|
||||
(value) => setPerformers(value),
|
||||
newPerformers,
|
||||
createNewPerformer
|
||||
)}
|
||||
{renderScrapedTagsRow(
|
||||
tags,
|
||||
(value) => setTags(value),
|
||||
newTags,
|
||||
createNewTag
|
||||
)}
|
||||
<ScrapedTextAreaRow
|
||||
title="Details"
|
||||
result={details}
|
||||
onChange={(value) => setDetails(value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrapeDialog
|
||||
title="Gallery Scrape Results"
|
||||
renderScrapeRows={renderScrapeRows}
|
||||
onClose={(apply) => {
|
||||
props.onClose(apply ? makeNewScrapedItem() : undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user