mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Support file-less scenes. Add scene split, merge and reassign file (#3006)
* Reassign scene file functionality * Implement scene create * Add scene create UI * Add sceneMerge backend support * Add merge scene to UI * Populate split create with scene details * Add merge button to duplicate checker * Handle file-less scenes in marker preview generate * Make unique file name for file-less scene exports * Add o-counter to scene update input * Hide rescan for file-less scenes * Generate heatmap if no speed set on file * Fix count in scene/image queries
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
useSceneUpdate,
|
||||
mutateReloadScrapers,
|
||||
queryScrapeSceneQueryFragment,
|
||||
mutateCreateScene,
|
||||
} from "src/core/StashService";
|
||||
import {
|
||||
PerformerSelect,
|
||||
@@ -34,7 +35,8 @@ import useToast from "src/hooks/Toast";
|
||||
import { ImageUtils, FormUtils, getStashIDs } from "src/utils";
|
||||
import { MovieSelect } from "src/components/Shared/Select";
|
||||
import { useFormik } from "formik";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import { Prompt, useHistory } from "react-router-dom";
|
||||
import queryString from "query-string";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { stashboxDisplayName } from "src/utils/stashbox";
|
||||
import { SceneMovieTable } from "./SceneMovieTable";
|
||||
@@ -50,19 +52,28 @@ const SceneScrapeDialog = lazy(() => import("./SceneScrapeDialog"));
|
||||
const SceneQueryModal = lazy(() => import("./SceneQueryModal"));
|
||||
|
||||
interface IProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
scene: Partial<GQL.SceneDataFragment>;
|
||||
initialCoverImage?: string;
|
||||
isNew?: boolean;
|
||||
isVisible: boolean;
|
||||
onDelete: () => void;
|
||||
onUpdate?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export const SceneEditPanel: React.FC<IProps> = ({
|
||||
scene,
|
||||
initialCoverImage,
|
||||
isNew = false,
|
||||
isVisible,
|
||||
onDelete,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const history = useHistory();
|
||||
|
||||
const queryParams = queryString.parse(location.search);
|
||||
|
||||
const fileID = (queryParams?.file_id ?? "") as string;
|
||||
|
||||
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
|
||||
[]
|
||||
);
|
||||
@@ -84,15 +95,17 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
setCoverImagePreview(scene.paths.screenshot ?? undefined);
|
||||
}, [scene.paths.screenshot]);
|
||||
setCoverImagePreview(
|
||||
initialCoverImage ?? scene.paths?.screenshot ?? undefined
|
||||
);
|
||||
}, [scene.paths?.screenshot, initialCoverImage]);
|
||||
|
||||
useEffect(() => {
|
||||
setGalleries(
|
||||
scene.galleries.map((g) => ({
|
||||
scene.galleries?.map((g) => ({
|
||||
id: g.id,
|
||||
title: objectTitle(g),
|
||||
}))
|
||||
})) ?? []
|
||||
);
|
||||
}, [scene.galleries]);
|
||||
|
||||
@@ -142,10 +155,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
return { movie_id: m.movie.id, scene_index: m.scene_index };
|
||||
}),
|
||||
tag_ids: (scene.tags ?? []).map((t) => t.id),
|
||||
cover_image: undefined,
|
||||
cover_image: initialCoverImage,
|
||||
stash_ids: getStashIDs(scene.stash_ids),
|
||||
}),
|
||||
[scene]
|
||||
[scene, initialCoverImage]
|
||||
);
|
||||
|
||||
type InputValues = typeof initialValues;
|
||||
@@ -154,7 +167,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
initialValues,
|
||||
enableReinitialize: true,
|
||||
validationSchema: schema,
|
||||
onSubmit: (values) => onSave(getSceneInput(values)),
|
||||
onSubmit: (values) => onSave(values),
|
||||
});
|
||||
|
||||
function setRating(v: number) {
|
||||
@@ -180,7 +193,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
formik.handleSubmit();
|
||||
});
|
||||
Mousetrap.bind("d d", () => {
|
||||
onDelete();
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
});
|
||||
|
||||
// numeric keypresses get caught by jwplayer, so blur the element
|
||||
@@ -234,7 +249,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
|
||||
function getSceneInput(input: InputValues): GQL.SceneUpdateInput {
|
||||
return {
|
||||
id: scene.id,
|
||||
id: scene.id!,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
@@ -256,27 +271,49 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
formik.setFieldValue("movies", newMovies);
|
||||
}
|
||||
|
||||
async function onSave(input: GQL.SceneUpdateInput) {
|
||||
function getCreateValues(values: InputValues): GQL.SceneCreateInput {
|
||||
return {
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
async function onSave(input: InputValues) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateScene({
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
rating: input.rating ?? null,
|
||||
if (!isNew) {
|
||||
const updateValues = getSceneInput(input);
|
||||
const result = await updateScene({
|
||||
variables: {
|
||||
input: {
|
||||
...updateValues,
|
||||
id: scene.id!,
|
||||
rating: input.rating ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (result.data?.sceneUpdate) {
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{ entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() }
|
||||
),
|
||||
});
|
||||
// clear the cover image so that it doesn't appear dirty
|
||||
formik.resetForm({ values: formik.values });
|
||||
if (result.data?.sceneUpdate) {
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{
|
||||
entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(),
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const createValues = getCreateValues(input);
|
||||
const result = await mutateCreateScene({
|
||||
...createValues,
|
||||
file_ids: fileID ? [fileID as string] : undefined,
|
||||
});
|
||||
if (result.data?.sceneCreate?.id) {
|
||||
history.push(`/scenes/${result.data?.sceneCreate.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// clear the cover image so that it doesn't appear dirty
|
||||
formik.resetForm({ values: formik.values });
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
@@ -316,7 +353,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapeScene(s, scene.id);
|
||||
const result = await queryScrapeScene(s, scene.id!);
|
||||
if (!result.data || !result.data.scrapeSingleScene?.length) {
|
||||
Toast.success({
|
||||
content: "No scenes found",
|
||||
@@ -399,7 +436,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
|
||||
const currentScene = getSceneInput(formik.values);
|
||||
if (!currentScene.cover_image) {
|
||||
currentScene.cover_image = scene.paths.screenshot;
|
||||
currentScene.cover_image = scene.paths!.screenshot;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -670,6 +707,24 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const image = useMemo(() => {
|
||||
if (imageEncoding) {
|
||||
return <LoadingIndicator message="Encoding image..." />;
|
||||
}
|
||||
|
||||
if (coverImagePreview) {
|
||||
return (
|
||||
<img
|
||||
className="scene-cover"
|
||||
src={coverImagePreview}
|
||||
alt={intl.formatMessage({ id: "cover_image" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div></div>;
|
||||
}, [imageEncoding, coverImagePreview, intl]);
|
||||
|
||||
if (isLoading) return <LoadingIndicator />;
|
||||
|
||||
return (
|
||||
@@ -687,25 +742,29 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="primary"
|
||||
disabled={!formik.dirty}
|
||||
disabled={!isNew && !formik.dirty}
|
||||
onClick={() => formik.submitForm()}
|
||||
>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</Button>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ml-auto pr-3 text-right d-flex">
|
||||
<ButtonGroup className="scraper-group">
|
||||
{renderScraperMenu()}
|
||||
{renderScrapeQueryMenu()}
|
||||
</ButtonGroup>
|
||||
{onDelete && (
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!isNew && (
|
||||
<div className="ml-auto pr-3 text-right d-flex">
|
||||
<ButtonGroup className="scraper-group">
|
||||
{renderScraperMenu()}
|
||||
{renderScrapeQueryMenu()}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-container row px-3">
|
||||
<div className="col-12 col-lg-7 col-xl-12">
|
||||
@@ -758,8 +817,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
})}
|
||||
<Col sm={9}>
|
||||
<GallerySelect
|
||||
galleries={galleries}
|
||||
selected={galleries}
|
||||
onSelect={(items) => onSetGalleries(items)}
|
||||
isMulti
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
@@ -918,15 +978,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
||||
<Form.Label>
|
||||
<FormattedMessage id="cover_image" />
|
||||
</Form.Label>
|
||||
{imageEncoding ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
<img
|
||||
className="scene-cover"
|
||||
src={coverImagePreview}
|
||||
alt={intl.formatMessage({ id: "cover_image" })}
|
||||
/>
|
||||
)}
|
||||
{image}
|
||||
<ImageInput
|
||||
isEditing
|
||||
onImageChange={onCoverImageChange}
|
||||
|
||||
Reference in New Issue
Block a user