Show and allow creation of unknown performers/tags/studios/movies from scraper dialog (#741)

* Fix toast container z-index
* Make scrape operations network only
* Add CollapseButton component
This commit is contained in:
WithoutPants
2020-08-22 18:12:39 +10:00
committed by GitHub
parent 2cdec6bde1
commit 1fd3fcc6a8
14 changed files with 388 additions and 46 deletions

View File

@@ -19,7 +19,7 @@ fragment ScrapedPerformerData on ScrapedPerformer {
}
fragment ScrapedScenePerformerData on ScrapedScenePerformer {
id
stored_id
name
gender
url
@@ -62,7 +62,7 @@ fragment ScrapedMovieData on ScrapedMovie {
}
fragment ScrapedSceneMovieData on ScrapedSceneMovie {
id
stored_id
name
aliases
duration
@@ -74,13 +74,13 @@ fragment ScrapedSceneMovieData on ScrapedSceneMovie {
}
fragment ScrapedSceneStudioData on ScrapedSceneStudio {
id
stored_id
name
url
}
fragment ScrapedSceneTagData on ScrapedSceneTag {
id
stored_id
name
}

View File

@@ -27,7 +27,7 @@ type Scraper {
type ScrapedScenePerformer {
"""Set if performer matched"""
id: ID
stored_id: ID
name: String!
gender: String
url: String
@@ -48,7 +48,7 @@ type ScrapedScenePerformer {
type ScrapedSceneMovie {
"""Set if movie matched"""
id: ID
stored_id: ID
name: String!
aliases: String
duration: String
@@ -61,14 +61,14 @@ type ScrapedSceneMovie {
type ScrapedSceneStudio {
"""Set if studio matched"""
id: ID
stored_id: ID
name: String!
url: String
}
type ScrapedSceneTag {
"""Set if tag matched"""
id: ID
stored_id: ID
name: String!
}

View File

@@ -43,6 +43,22 @@ func (r *Resolver) Tag() models.TagResolver {
return &tagResolver{r}
}
func (r *Resolver) ScrapedSceneTag() models.ScrapedSceneTagResolver {
return &scrapedSceneTagResolver{r}
}
func (r *Resolver) ScrapedSceneMovie() models.ScrapedSceneMovieResolver {
return &scrapedSceneMovieResolver{r}
}
func (r *Resolver) ScrapedScenePerformer() models.ScrapedScenePerformerResolver {
return &scrapedScenePerformerResolver{r}
}
func (r *Resolver) ScrapedSceneStudio() models.ScrapedSceneStudioResolver {
return &scrapedSceneStudioResolver{r}
}
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver }
@@ -54,6 +70,10 @@ type sceneMarkerResolver struct{ *Resolver }
type studioResolver struct{ *Resolver }
type movieResolver struct{ *Resolver }
type tagResolver struct{ *Resolver }
type scrapedSceneTagResolver struct{ *Resolver }
type scrapedSceneMovieResolver struct{ *Resolver }
type scrapedScenePerformerResolver struct{ *Resolver }
type scrapedSceneStudioResolver struct{ *Resolver }
func (r *queryResolver) MarkerWall(ctx context.Context, q *string) ([]*models.SceneMarker, error) {
qb := models.NewSceneMarkerQueryBuilder()

View File

@@ -0,0 +1,23 @@
package api
import (
"context"
"github.com/stashapp/stash/pkg/models"
)
func (r *scrapedSceneTagResolver) StoredID(ctx context.Context, obj *models.ScrapedSceneTag) (*string, error) {
return obj.ID, nil
}
func (r *scrapedSceneMovieResolver) StoredID(ctx context.Context, obj *models.ScrapedSceneMovie) (*string, error) {
return obj.ID, nil
}
func (r *scrapedScenePerformerResolver) StoredID(ctx context.Context, obj *models.ScrapedScenePerformer) (*string, error) {
return obj.ID, nil
}
func (r *scrapedSceneStudioResolver) StoredID(ctx context.Context, obj *models.ScrapedSceneStudio) (*string, error) {
return obj.ID, nil
}

View File

@@ -132,7 +132,7 @@ type ScrapedSceneMovie struct {
type ScrapedSceneTag struct {
// Set if tag matched
ID *string `graphql:"id" json:"id"`
ID *string `graphql:"stored_id" json:"stored_id"`
Name string `graphql:"name" json:"name"`
}

View File

@@ -5,6 +5,7 @@ const markup = `
#### 💥 **Note: After upgrading, the next scan will populate all scenes with oshash hashes. MD5 calculation can be disabled after populating the oshash for all scenes. See \`Hashing Algorithms\` in the \`Configuration\` section of the manual for details. **
### ✨ New Features
* Show and allow creation of unknown performers/tags/studios/movies in the scraper dialog.
* Add support for scraping movie details.
* Add support for JSON scrapers.
* Add support for plugin tasks.

View File

@@ -351,39 +351,39 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
setUrl(scene.url);
}
if (scene.studio && scene.studio.id) {
setStudioId(scene.studio.id);
if (scene.studio && scene.studio.stored_id) {
setStudioId(scene.studio.stored_id);
}
if (scene.performers && scene.performers.length > 0) {
const idPerfs = scene.performers.filter((p) => {
return p.id !== undefined && p.id !== null;
return p.stored_id !== undefined && p.stored_id !== null;
});
if (idPerfs.length > 0) {
const newIds = idPerfs.map((p) => p.id);
const newIds = idPerfs.map((p) => p.stored_id);
setPerformerIds(newIds as string[]);
}
}
if (scene.movies && scene.movies.length > 0) {
const idMovis = scene.movies.filter((p) => {
return p.id !== undefined && p.id !== null;
return p.stored_id !== undefined && p.stored_id !== null;
});
if (idMovis.length > 0) {
const newIds = idMovis.map((p) => p.id);
const newIds = idMovis.map((p) => p.stored_id);
setMovieIds(newIds as string[]);
}
}
if (scene?.tags?.length) {
const idTags = scene.tags.filter((p) => {
return p.id !== undefined && p.id !== null;
return p.stored_id !== undefined && p.stored_id !== null;
});
if (idTags.length > 0) {
const newIds = idTags.map((p) => p.id);
const newIds = idTags.map((p) => p.stored_id);
setTagIds(newIds as string[]);
}
}

View File

@@ -11,6 +11,13 @@ import {
ScrapedImageRow,
} from "src/components/Shared/ScrapeDialog";
import _ from "lodash";
import {
useStudioCreate,
usePerformerCreate,
useMovieCreate,
useTagCreate,
} from "src/core/StashService";
import { useToast } from "src/hooks";
function renderScrapedStudio(
result: ScrapeResult<string>,
@@ -36,7 +43,9 @@ function renderScrapedStudio(
function renderScrapedStudioRow(
result: ScrapeResult<string>,
onChange: (value: ScrapeResult<string>) => void
onChange: (value: ScrapeResult<string>) => void,
newStudio?: GQL.ScrapedSceneStudio,
onCreateNew?: (value: GQL.ScrapedSceneStudio) => void
) {
return (
<ScrapeDialogRow
@@ -49,6 +58,8 @@ function renderScrapedStudioRow(
)
}
onChange={onChange}
newValues={newStudio ? [newStudio] : undefined}
onCreateNew={onCreateNew}
/>
);
}
@@ -78,7 +89,9 @@ function renderScrapedPerformers(
function renderScrapedPerformersRow(
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void
onChange: (value: ScrapeResult<string[]>) => void,
newPerformers: GQL.ScrapedScenePerformer[],
onCreateNew?: (value: GQL.ScrapedScenePerformer) => void
) {
return (
<ScrapeDialogRow
@@ -91,6 +104,8 @@ function renderScrapedPerformersRow(
)
}
onChange={onChange}
newValues={newPerformers}
onCreateNew={onCreateNew}
/>
);
}
@@ -120,7 +135,9 @@ function renderScrapedMovies(
function renderScrapedMoviesRow(
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void
onChange: (value: ScrapeResult<string[]>) => void,
newMovies: GQL.ScrapedSceneMovie[],
onCreateNew?: (value: GQL.ScrapedSceneMovie) => void
) {
return (
<ScrapeDialogRow
@@ -133,6 +150,8 @@ function renderScrapedMoviesRow(
)
}
onChange={onChange}
newValues={newMovies}
onCreateNew={onCreateNew}
/>
);
}
@@ -162,7 +181,9 @@ function renderScrapedTags(
function renderScrapedTagsRow(
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void
onChange: (value: ScrapeResult<string[]>) => void,
newTags: GQL.ScrapedSceneTag[],
onCreateNew?: (value: GQL.ScrapedSceneTag) => void
) {
return (
<ScrapeDialogRow
@@ -174,7 +195,9 @@ function renderScrapedTagsRow(
onChange(result.cloneWithValue(value))
)
}
newValues={newTags}
onChange={onChange}
onCreateNew={onCreateNew}
/>
);
}
@@ -186,8 +209,8 @@ interface ISceneScrapeDialogProps {
onClose: (scrapedScene?: GQL.ScrapedScene) => void;
}
interface IHasID {
id?: string | null;
interface IHasStoredID {
stored_id?: string | null;
}
export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
@@ -203,15 +226,27 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
new ScrapeResult<string>(props.scene.date, props.scraped.date)
);
const [studio, setStudio] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.scene.studio_id, props.scraped.studio?.id)
new ScrapeResult<string>(
props.scene.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 mapIdObjects(scrapedObjects?: IHasID[]): string[] | undefined {
function mapStoredIdObjects(
scrapedObjects?: IHasStoredID[]
): string[] | undefined {
if (!scrapedObjects) {
return undefined;
}
const ret = scrapedObjects
.map((p) => p.id)
.map((p) => p.stored_id)
.filter((p) => {
return p !== undefined && p !== null;
}) as string[];
@@ -245,21 +280,33 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
const [performers, setPerformers] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
sortIdList(props.scene.performer_ids),
mapIdObjects(props.scraped.performers ?? undefined)
mapStoredIdObjects(props.scraped.performers ?? undefined)
)
);
const [newPerformers, setNewPerformers] = useState<
GQL.ScrapedScenePerformer[]
>(props.scraped.performers?.filter((t) => !t.stored_id) ?? []);
const [movies, setMovies] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
sortIdList(props.scene.movies?.map((p) => p.movie_id)),
mapIdObjects(props.scraped.movies ?? undefined)
mapStoredIdObjects(props.scraped.movies ?? undefined)
)
);
const [newMovies, setNewMovies] = useState<GQL.ScrapedSceneMovie[]>(
props.scraped.movies?.filter((t) => !t.stored_id) ?? []
);
const [tags, setTags] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
sortIdList(props.scene.tag_ids),
mapIdObjects(props.scraped.tags ?? undefined)
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.scene.details, props.scraped.details)
);
@@ -267,6 +314,13 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
new ScrapeResult<string>(props.scene.cover_image, props.scraped.image)
);
const [createStudio] = useStudioCreate({ name: "" });
const [createPerformer] = usePerformerCreate();
const [createMovie] = useMovieCreate({ name: "" });
const [createTag] = useTagCreate({ name: "" });
const Toast = useToast();
// don't show the dialog if nothing was scraped
if (
[title, url, date, studio, performers, movies, tags, details, image].every(
@@ -277,34 +331,164 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
return <></>;
}
function makeNewScrapedItem() {
const newStudio = studio.getNewValue();
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 createNewMovie(toCreate: GQL.ScrapedSceneMovie) {
let movieInput: GQL.MovieCreateInput = { name: "" };
try {
movieInput = Object.assign(movieInput, toCreate);
const result = await createMovie({
variables: movieInput,
});
// add the new movie to the new movies value
const movieClone = movies.cloneWithValue(movies.newValue);
if (!movieClone.newValue) {
movieClone.newValue = [];
}
movieClone.newValue.push(result.data!.movieCreate!.id);
setMovies(movieClone);
// remove the movie from the list
const newMoviesClone = newMovies.concat();
const pIndex = newMoviesClone.indexOf(toCreate);
newMoviesClone.splice(pIndex, 1);
setNewMovies(newMoviesClone);
Toast.success({
content: (
<span>
Created movie: <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.ScrapedSceneDataFragment {
const newStudioValue = studio.getNewValue();
return {
title: title.getNewValue(),
url: url.getNewValue(),
date: date.getNewValue(),
studio: newStudio
studio: newStudioValue
? {
id: newStudio,
stored_id: newStudioValue,
name: "",
}
: undefined,
performers: performers.getNewValue()?.map((p) => {
return {
id: p,
stored_id: p,
name: "",
};
}),
movies: movies.getNewValue()?.map((m) => {
return {
id: m,
stored_id: m,
name: "",
};
}),
tags: tags.getNewValue()?.map((m) => {
return {
id: m,
stored_id: m,
name: "",
};
}),
@@ -332,12 +516,30 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = (
result={date}
onChange={(value) => setDate(value)}
/>
{renderScrapedStudioRow(studio, (value) => setStudio(value))}
{renderScrapedPerformersRow(performers, (value) =>
setPerformers(value)
{renderScrapedStudioRow(
studio,
(value) => setStudio(value),
newStudio,
createNewStudio
)}
{renderScrapedPerformersRow(
performers,
(value) => setPerformers(value),
newPerformers,
createNewPerformer
)}
{renderScrapedMoviesRow(
movies,
(value) => setMovies(value),
newMovies,
createNewMovie
)}
{renderScrapedTagsRow(
tags,
(value) => setTags(value),
newTags,
createNewTag
)}
{renderScrapedMoviesRow(movies, (value) => setMovies(value))}
{renderScrapedTagsRow(tags, (value) => setTags(value))}
<ScrapedTextAreaRow
title="Details"
result={details}

View File

@@ -0,0 +1,28 @@
import React, { useState } from "react";
import { Button, Collapse } from "react-bootstrap";
import { Icon } from "src/components/Shared";
interface IProps {
text: string;
}
export const CollapseButton: React.FC<React.PropsWithChildren<IProps>> = (
props: React.PropsWithChildren<IProps>
) => {
const [open, setOpen] = useState(false);
return (
<div>
<Button
onClick={() => setOpen(!open)}
className="minimal collapse-button"
>
<Icon icon={open ? "chevron-down" : "chevron-right"} />
<span>{props.text}</span>
</Button>
<Collapse in={open}>
<div>{props.children}</div>
</Collapse>
</div>
);
};

View File

@@ -6,8 +6,9 @@ import {
InputGroup,
Button,
FormControl,
Badge,
} from "react-bootstrap";
import { Icon, Modal } from "src/components/Shared";
import { CollapseButton, Icon, Modal } from "src/components/Shared";
import _ from "lodash";
export class ScrapeResult<T> {
@@ -35,6 +36,7 @@ export class ScrapeResult<T> {
ret.newValue = value;
ret.useNewValue = !_.isEqual(ret.newValue, ret.originalValue);
ret.scraped = ret.useNewValue;
return ret;
}
@@ -46,15 +48,22 @@ export class ScrapeResult<T> {
}
}
interface IHasName {
name: string;
}
interface IScrapedFieldProps<T> {
result: ScrapeResult<T>;
}
interface IScrapedRowProps<T> extends IScrapedFieldProps<T> {
interface IScrapedRowProps<T, V extends IHasName>
extends IScrapedFieldProps<T> {
title: string;
renderOriginalField: (result: ScrapeResult<T>) => JSX.Element | undefined;
renderNewField: (result: ScrapeResult<T>) => JSX.Element | undefined;
onChange: (value: ScrapeResult<T>) => void;
newValues?: V[];
onCreateNew?: (newValue: V) => void;
}
function renderButtonIcon(selected: boolean) {
@@ -68,17 +77,59 @@ function renderButtonIcon(selected: boolean) {
);
}
export const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {
export const ScrapeDialogRow = <T, V extends IHasName>(
props: IScrapedRowProps<T, V>
) => {
function handleSelectClick(isNew: boolean) {
const ret = _.clone(props.result);
ret.useNewValue = isNew;
props.onChange(ret);
}
if (!props.result.scraped) {
function hasNewValues() {
return props.newValues && props.newValues.length > 0 && props.onCreateNew;
}
if (!props.result.scraped && !hasNewValues()) {
return <></>;
}
function renderNewValues() {
if (!hasNewValues()) {
return;
}
const ret = (
<>
{props.newValues!.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => props.onCreateNew!(t)}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon="plus" />
</Button>
</Badge>
))}
</>
);
const minCollapseLength = 10;
if (props.newValues!.length >= minCollapseLength) {
return (
<CollapseButton text={`Missing (${props.newValues!.length})`}>
{ret}
</CollapseButton>
);
}
return ret;
}
return (
<Row className="px-3 pt-3">
<Form.Label column lg="3">
@@ -112,6 +163,7 @@ export const ScrapeDialogRow = <T,>(props: IScrapedRowProps<T>) => {
</InputGroup.Prepend>
{props.renderNewField(props.result)}
</InputGroup>
{renderNewValues()}
</Col>
</Row>
</Col>

View File

@@ -10,6 +10,7 @@ export {
export { default as Icon } from "./Icon";
export { default as Modal } from "./Modal";
export { CollapseButton } from "./CollapseButton";
export { DetailsEditNavbar } from "./DetailsEditNavbar";
export { DurationInput } from "./DurationInput";
export { TagLink } from "./TagLink";

View File

@@ -119,3 +119,12 @@
overflow-y: auto;
padding-right: 15px;
}
button.collapse-button.btn-primary:not(:disabled):not(.disabled):hover,
button.collapse-button.btn-primary:not(:disabled):not(.disabled):focus,
button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
background: none;
border: none;
box-shadow: none;
color: #f5f8fa;
}

View File

@@ -405,6 +405,7 @@ export const queryScrapeFreeones = (performerName: string) =>
variables: {
performer_name: performerName,
},
fetchPolicy: "network-only",
});
export const queryScrapePerformer = (
@@ -417,6 +418,7 @@ export const queryScrapePerformer = (
scraper_id: scraperId,
scraped_performer: scrapedPerformer,
},
fetchPolicy: "network-only",
});
export const queryScrapePerformerURL = (url: string) =>
@@ -425,6 +427,7 @@ export const queryScrapePerformerURL = (url: string) =>
variables: {
url,
},
fetchPolicy: "network-only",
});
export const queryScrapeSceneURL = (url: string) =>
@@ -433,6 +436,7 @@ export const queryScrapeSceneURL = (url: string) =>
variables: {
url,
},
fetchPolicy: "network-only",
});
export const queryScrapeMovieURL = (url: string) =>
@@ -441,6 +445,7 @@ export const queryScrapeMovieURL = (url: string) =>
variables: {
url,
},
fetchPolicy: "network-only",
});
export const queryScrapeScene = (
@@ -453,6 +458,7 @@ export const queryScrapeScene = (
scraper_id: scraperId,
scene,
},
fetchPolicy: "network-only",
});
export const mutateReloadScrapers = () =>

View File

@@ -343,7 +343,7 @@ div.dropdown-menu {
max-width: 350px;
position: fixed;
top: 2rem;
z-index: 1031;
z-index: 1051;
.success {
background-color: $success;