Tasks page refactor (#1949)

* Add scan dialog
* Add Auto Tag dialog
* Refactor and combine Generate dialog
* Add clean dialog
* Support scan task default setting
* Support saving auto tag defaults
* Support for generate defaults
This commit is contained in:
WithoutPants
2021-11-24 09:09:47 +11:00
committed by GitHub
parent 9e558d92f2
commit ff32f22c1a
33 changed files with 1903 additions and 894 deletions

View File

@@ -106,6 +106,16 @@ fragment ScraperSourceData on ScraperSource {
} }
fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult { fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {
scan {
useFileMetadata
stripFileExtension
scanGeneratePreviews
scanGenerateImagePreviews
scanGenerateSprites
scanGeneratePhashes
scanGenerateThumbnails
}
identify { identify {
sources { sources {
source { source {
@@ -120,6 +130,30 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {
} }
} }
autoTag {
performers
studios
tags
}
generate {
sprites
previews
imagePreviews
previewOptions {
previewSegments
previewSegmentDuration
previewExcludeStart
previewExcludeEnd
previewPreset
}
markers
markerImagePreviews
markerScreenshots
transcodes
phashes
}
deleteFile deleteFile
deleteGenerated deleteGenerated
} }

View File

@@ -333,7 +333,10 @@ type ConfigScrapingResult {
} }
type ConfigDefaultSettingsResult { type ConfigDefaultSettingsResult {
scan: ScanMetadataOptions
identify: IdentifyMetadataTaskOptions identify: IdentifyMetadataTaskOptions
autoTag: AutoTagMetadataOptions
generate: GenerateMetadataOptions
"""If true, delete file checkbox will be checked by default""" """If true, delete file checkbox will be checked by default"""
deleteFile: Boolean deleteFile: Boolean
@@ -342,7 +345,10 @@ type ConfigDefaultSettingsResult {
} }
input ConfigDefaultSettingsInput { input ConfigDefaultSettingsInput {
scan: ScanMetadataInput
identify: IdentifyMetadataInput identify: IdentifyMetadataInput
autoTag: AutoTagMetadataInput
generate: GenerateMetadataInput
"""If true, delete file checkbox will be checked by default""" """If true, delete file checkbox will be checked by default"""
deleteFile: Boolean deleteFile: Boolean

View File

@@ -33,6 +33,31 @@ input GeneratePreviewOptionsInput {
previewPreset: PreviewPreset previewPreset: PreviewPreset
} }
type GenerateMetadataOptions {
sprites: Boolean
previews: Boolean
imagePreviews: Boolean
previewOptions: GeneratePreviewOptions
markers: Boolean
markerImagePreviews: Boolean
markerScreenshots: Boolean
transcodes: Boolean
phashes: Boolean
}
type GeneratePreviewOptions {
"""Number of segments in a preview file"""
previewSegments: Int
"""Preview segment duration, in seconds"""
previewSegmentDuration: Float
"""Duration of start of video to exclude when generating previews"""
previewExcludeStart: String
"""Duration of end of video to exclude when generating previews"""
previewExcludeEnd: String
"""Preset when generating preview"""
previewPreset: PreviewPreset
}
input ScanMetadataInput { input ScanMetadataInput {
paths: [String!] paths: [String!]
"""Set name, date, details from metadata (if present)""" """Set name, date, details from metadata (if present)"""
@@ -51,6 +76,23 @@ input ScanMetadataInput {
scanGenerateThumbnails: Boolean scanGenerateThumbnails: Boolean
} }
type ScanMetadataOptions {
"""Set name, date, details from metadata (if present)"""
useFileMetadata: Boolean!
"""Strip file extension from title"""
stripFileExtension: Boolean!
"""Generate previews during scan"""
scanGeneratePreviews: Boolean!
"""Generate image previews during scan"""
scanGenerateImagePreviews: Boolean!
"""Generate sprites during scan"""
scanGenerateSprites: Boolean!
"""Generate phashes during scan"""
scanGeneratePhashes: Boolean!
"""Generate image thumbnails during scan"""
scanGenerateThumbnails: Boolean!
}
input CleanMetadataInput { input CleanMetadataInput {
"""Do a dry run. Don't delete any files""" """Do a dry run. Don't delete any files"""
dryRun: Boolean! dryRun: Boolean!
@@ -67,6 +109,15 @@ input AutoTagMetadataInput {
tags: [String!] tags: [String!]
} }
type AutoTagMetadataOptions {
"""IDs of performers to tag files with, or "*" for all"""
performers: [String!]
"""IDs of studios to tag files with, or "*" for all"""
studios: [String!]
"""IDs of tags to tag files with, or "*" for all"""
tags: [String!]
}
enum IdentifyFieldStrategy { enum IdentifyFieldStrategy {
"""Never sets the field value""" """Never sets the field value"""
IGNORE IGNORE

View File

@@ -394,6 +394,18 @@ func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input models.C
c.Set(config.DefaultIdentifySettings, input.Identify) c.Set(config.DefaultIdentifySettings, input.Identify)
} }
if input.Scan != nil {
c.Set(config.DefaultScanSettings, input.Scan)
}
if input.AutoTag != nil {
c.Set(config.DefaultAutoTagSettings, input.AutoTag)
}
if input.Generate != nil {
c.Set(config.DefaultGenerateSettings, input.Generate)
}
if input.DeleteFile != nil { if input.DeleteFile != nil {
c.Set(config.DeleteFileDefault, *input.DeleteFile) c.Set(config.DeleteFileDefault, *input.DeleteFile)
} }

View File

@@ -174,6 +174,9 @@ func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult {
return &models.ConfigDefaultSettingsResult{ return &models.ConfigDefaultSettingsResult{
Identify: config.GetDefaultIdentifySettings(), Identify: config.GetDefaultIdentifySettings(),
Scan: config.GetDefaultScanSettings(),
AutoTag: config.GetDefaultAutoTagSettings(),
Generate: config.GetDefaultGenerateSettings(),
DeleteFile: &deleteFileDefault, DeleteFile: &deleteFileDefault,
DeleteGenerated: &deleteGeneratedDefault, DeleteGenerated: &deleteGeneratedDefault,
} }

View File

@@ -168,7 +168,11 @@ const (
LogAccess = "logAccess" LogAccess = "logAccess"
defaultLogAccess = true defaultLogAccess = true
// Default settings
DefaultScanSettings = "defaults.scan_task"
DefaultIdentifySettings = "defaults.identify_task" DefaultIdentifySettings = "defaults.identify_task"
DefaultAutoTagSettings = "defaults.auto_tag_task"
DefaultGenerateSettings = "defaults.generate_task"
DeleteFileDefault = "defaults.delete_file" DeleteFileDefault = "defaults.delete_file"
DeleteGeneratedDefault = "defaults.delete_generated" DeleteGeneratedDefault = "defaults.delete_generated"
@@ -953,6 +957,63 @@ func (i *Instance) GetDefaultIdentifySettings() *models.IdentifyMetadataTaskOpti
return nil return nil
} }
// GetDefaultScanSettings returns the default Scan task settings.
// Returns nil if the settings could not be unmarshalled, or if it
// has not been set.
func (i *Instance) GetDefaultScanSettings() *models.ScanMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultScanSettings)
if v.IsSet(DefaultScanSettings) {
var ret models.ScanMetadataOptions
if err := v.UnmarshalKey(DefaultScanSettings, &ret); err != nil {
return nil
}
return &ret
}
return nil
}
// GetDefaultAutoTagSettings returns the default Scan task settings.
// Returns nil if the settings could not be unmarshalled, or if it
// has not been set.
func (i *Instance) GetDefaultAutoTagSettings() *models.AutoTagMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultAutoTagSettings)
if v.IsSet(DefaultAutoTagSettings) {
var ret models.AutoTagMetadataOptions
if err := v.UnmarshalKey(DefaultAutoTagSettings, &ret); err != nil {
return nil
}
return &ret
}
return nil
}
// GetDefaultGenerateSettings returns the default Scan task settings.
// Returns nil if the settings could not be unmarshalled, or if it
// has not been set.
func (i *Instance) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultGenerateSettings)
if v.IsSet(DefaultGenerateSettings) {
var ret models.GenerateMetadataOptions
if err := v.UnmarshalKey(DefaultGenerateSettings, &ret); err != nil {
return nil
}
return &ret
}
return nil
}
// GetTrustedProxies returns a comma separated list of ip addresses that should allow proxying. // GetTrustedProxies returns a comma separated list of ip addresses that should allow proxying.
// When empty, allow from any private network // When empty, allow from any private network
func (i *Instance) GetTrustedProxies() []string { func (i *Instance) GetTrustedProxies() []string {

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ New Features
* Refactored tasks page and dialogs and added support for saving default options for scanning, generating and auto-tagging. ([#1949](https://github.com/stashapp/stash/pull/1949))
* Changed query string parsing behaviour to require all words by default, with the option to `or` keywords and exclude keywords. See the `Browsing` section of the manual for details. ([#1982](https://github.com/stashapp/stash/pull/1982)) * Changed query string parsing behaviour to require all words by default, with the option to `or` keywords and exclude keywords. See the `Browsing` section of the manual for details. ([#1982](https://github.com/stashapp/stash/pull/1982))
* Add forward jump 10 second button to video player. ([#1973](https://github.com/stashapp/stash/pull/1973)) * Add forward jump 10 second button to video player. ([#1973](https://github.com/stashapp/stash/pull/1973))

View File

@@ -0,0 +1,262 @@
import React, { useState, useMemo, useEffect } from "react";
import { Button, Form } from "react-bootstrap";
import {
mutateMetadataAutoTag,
useConfiguration,
useConfigureDefaults,
} from "src/core/StashService";
import { Icon, Modal, OperationButton } from "src/components/Shared";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { FormattedMessage, useIntl } from "react-intl";
import { DirectorySelectionDialog } from "src/components/Settings/SettingsTasksPanel/DirectorySelectionDialog";
import { Manual } from "src/components/Help/Manual";
import { withoutTypename } from "src/utils";
interface IAutoTagOptions {
options: GQL.AutoTagMetadataInput;
setOptions: (s: GQL.AutoTagMetadataInput) => void;
}
const AutoTagOptions: React.FC<IAutoTagOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
const { performers, studios, tags } = options;
const wildcard = ["*"];
function toggle(v?: GQL.Maybe<string[]>) {
if (!v?.length) {
return wildcard;
}
return [];
}
function setOptions(input: Partial<GQL.AutoTagMetadataInput>) {
setOptionsState({ ...options, ...input });
}
return (
<Form.Group>
<Form.Check
id="autotag-performers"
checked={!!performers?.length}
label={intl.formatMessage({ id: "performers" })}
onChange={() => setOptions({ performers: toggle(performers) })}
/>
<Form.Check
id="autotag-studios"
checked={!!studios?.length}
label={intl.formatMessage({ id: "studios" })}
onChange={() => setOptions({ studios: toggle(studios) })}
/>
<Form.Check
id="autotag-tags"
checked={!!tags?.length}
label={intl.formatMessage({ id: "tags" })}
onChange={() => setOptions({ tags: toggle(tags) })}
/>
</Form.Group>
);
};
interface IAutoTagDialogProps {
onClose: () => void;
}
export const AutoTagDialog: React.FC<IAutoTagDialogProps> = ({ onClose }) => {
const [configureDefaults] = useConfigureDefaults();
const [options, setOptions] = useState<GQL.AutoTagMetadataInput>({
performers: ["*"],
studios: ["*"],
tags: ["*"],
});
const [paths, setPaths] = useState<string[]>([]);
const [showManual, setShowManual] = useState(false);
const [settingPaths, setSettingPaths] = useState(false);
const [animation, setAnimation] = useState(true);
const [savingDefaults, setSavingDefaults] = useState(false);
const intl = useIntl();
const Toast = useToast();
const { data: configData, error: configError } = useConfiguration();
useEffect(() => {
if (!configData?.configuration.defaults) {
return;
}
const { autoTag } = configData.configuration.defaults;
if (autoTag) {
setOptions(withoutTypename(autoTag));
}
}, [configData]);
const selectionStatus = useMemo(() => {
const message = paths.length ? (
<div>
<FormattedMessage id="config.tasks.auto_tag.auto_tagging_paths" />:
<ul>
{paths.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</div>
) : (
<span>
<FormattedMessage id="config.tasks.auto_tag.auto_tagging_all_paths" />.
</span>
);
function onClick() {
setAnimation(false);
setSettingPaths(true);
}
return (
<Form.Group className="dialog-selected-folders">
<div>
{message}
<div>
<Button
title={intl.formatMessage({ id: "actions.select_folders" })}
onClick={() => onClick()}
>
<Icon icon="folder-open" />
</Button>
</div>
</div>
</Form.Group>
);
}, [intl, paths]);
if (configError) return <div>{configError}</div>;
if (!configData) return <div />;
function makeDefaultAutoTagInput() {
const ret = options;
const { paths: _paths, ...withoutSpecifics } = ret;
return withoutSpecifics;
}
async function onAutoTag() {
try {
await mutateMetadataAutoTag({
...options,
paths: paths.length ? paths : undefined,
});
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.auto_tag" }) }
),
});
} catch (e) {
Toast.error(e);
} finally {
onClose();
}
}
function onShowManual() {
setAnimation(false);
setShowManual(true);
}
async function setAsDefault() {
try {
setSavingDefaults(true);
await configureDefaults({
variables: {
input: {
autoTag: makeDefaultAutoTagInput(),
},
},
});
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.defaults_set" },
{ action: intl.formatMessage({ id: "actions.auto_tag" }) }
),
});
} catch (e) {
Toast.error(e);
} finally {
setSavingDefaults(false);
}
}
if (settingPaths) {
return (
<DirectorySelectionDialog
animation={false}
allowEmpty
initialPaths={paths}
onClose={(p) => {
if (p) {
setPaths(p);
}
setSettingPaths(false);
}}
/>
);
}
if (showManual) {
return (
<Manual
animation={false}
show
onClose={() => setShowManual(false)}
defaultActiveTab="AutoTagging.md"
/>
);
}
return (
<Modal
modalProps={{ animation }}
show
icon="cogs"
header={intl.formatMessage({ id: "actions.auto_tag" })}
accept={{
onClick: onAutoTag,
text: intl.formatMessage({ id: "actions.auto_tag" }),
}}
cancel={{
onClick: () => onClose(),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
disabled={savingDefaults}
footerButtons={
<OperationButton variant="secondary" operation={setAsDefault}>
<FormattedMessage id="actions.set_as_default" />
</OperationButton>
}
leftFooterButtons={
<Button
title="Help"
className="minimal help-button"
onClick={() => onShowManual()}
>
<Icon icon="question-circle" />
</Button>
}
>
<Form>
{selectionStatus}
<AutoTagOptions options={options} setOptions={(o) => setOptions(o)} />
</Form>
</Modal>
);
};
export default AutoTagDialog;

View File

@@ -0,0 +1,198 @@
import React, { useState, useMemo } from "react";
import { Button, Form } from "react-bootstrap";
import {
mutateMetadataClean,
useConfiguration,
// useConfigureDefaults,
} from "src/core/StashService";
import { Icon, Modal } from "src/components/Shared";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { useIntl } from "react-intl";
import { Manual } from "src/components/Help/Manual";
interface ICleanOptions {
options: GQL.CleanMetadataInput;
setOptions: (s: GQL.CleanMetadataInput) => void;
}
const CleanOptions: React.FC<ICleanOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
function setOptions(input: Partial<GQL.CleanMetadataInput>) {
setOptionsState({ ...options, ...input });
}
return (
<Form.Group>
<Form.Check
id="clean-dryrun"
checked={options.dryRun}
label={intl.formatMessage({ id: "config.tasks.only_dry_run" })}
onChange={() => setOptions({ dryRun: !options.dryRun })}
/>
</Form.Group>
);
};
interface ICleanDialog {
onClose: () => void;
}
export const CleanDialog: React.FC<ICleanDialog> = ({ onClose }) => {
const [options, setOptions] = useState<GQL.CleanMetadataInput>({
dryRun: false,
});
// TODO - selective clean
// const [paths, setPaths] = useState<string[]>([]);
// const [settingPaths, setSettingPaths] = useState(false);
const [showManual, setShowManual] = useState(false);
const [animation, setAnimation] = useState(true);
const intl = useIntl();
const Toast = useToast();
const { data: configData, error: configError } = useConfiguration();
const message = useMemo(() => {
if (options.dryRun) {
return (
<p>{intl.formatMessage({ id: "actions.tasks.dry_mode_selected" })}</p>
);
} else {
return (
<p>
{intl.formatMessage({ id: "actions.tasks.clean_confirm_message" })}
</p>
);
}
}, [options.dryRun, intl]);
// const selectionStatus = useMemo(() => {
// const message = paths.length ? (
// <div>
// <FormattedMessage id="config.tasks.auto_tag.auto_tagging_paths" />:
// <ul>
// {paths.map((p) => (
// <li key={p}>{p}</li>
// ))}
// </ul>
// </div>
// ) : (
// <span>
// <FormattedMessage id="config.tasks.auto_tag.auto_tagging_all_paths" />.
// </span>
// );
// function onClick() {
// setAnimation(false);
// setSettingPaths(true);
// }
// return (
// <Form.Group className="dialog-selected-folders">
// <div>
// {message}
// <div>
// <Button
// title={intl.formatMessage({ id: "actions.select_folders" })}
// onClick={() => onClick()}
// >
// <Icon icon="folder-open" />
// </Button>
// </div>
// </div>
// </Form.Group>
// );
// }, [intl, paths]);
if (configError) return <div>{configError}</div>;
if (!configData) return <div />;
async function onClean() {
try {
await mutateMetadataClean(options);
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.clean" }) }
),
});
} catch (e) {
Toast.error(e);
} finally {
onClose();
}
}
function onShowManual() {
setAnimation(false);
setShowManual(true);
}
// if (settingPaths) {
// return (
// <DirectorySelectionDialog
// animation={false}
// allowEmpty
// initialPaths={paths}
// onClose={(p) => {
// if (p) {
// setPaths(p);
// }
// setSettingPaths(false);
// }}
// />
// );
// }
if (showManual) {
return (
<Manual
animation={false}
show
onClose={() => setShowManual(false)}
defaultActiveTab="Tasks.md"
/>
);
}
return (
<Modal
modalProps={{ animation }}
show
icon="cogs"
header={intl.formatMessage({ id: "actions.clean" })}
accept={{
onClick: onClean,
variant: "danger",
text: intl.formatMessage({ id: "actions.clean" }),
}}
cancel={{
onClick: () => onClose(),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
leftFooterButtons={
<Button
title="Help"
className="minimal help-button"
onClick={() => onShowManual()}
>
<Icon icon="question-circle" />
</Button>
}
>
<Form>
<CleanOptions options={options} setOptions={(o) => setOptions(o)} />
{message}
</Form>
</Modal>
);
};
export default CleanDialog;

View File

@@ -0,0 +1,557 @@
import React, { useState, useEffect, useMemo } from "react";
import { Form, Button, Collapse } from "react-bootstrap";
import {
mutateMetadataGenerate,
useConfigureDefaults,
} from "src/core/StashService";
import { Modal, Icon, OperationButton } from "src/components/Shared";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { FormattedMessage, useIntl } from "react-intl";
import { ConfigurationContext } from "src/hooks/Config";
// import { DirectorySelectionDialog } from "../Settings/SettingsTasksPanel/DirectorySelectionDialog";
import { Manual } from "../Help/Manual";
import { withoutTypename } from "src/utils";
interface IGenerateOptions {
options: GQL.GenerateMetadataInput;
setOptions: (s: GQL.GenerateMetadataInput) => void;
}
const GenerateOptions: React.FC<IGenerateOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false);
const previewOptions: GQL.GeneratePreviewOptionsInput =
options.previewOptions ?? {};
function setOptions(input: Partial<GQL.GenerateMetadataInput>) {
setOptionsState({ ...options, ...input });
}
function setPreviewOptions(input: Partial<GQL.GeneratePreviewOptionsInput>) {
setOptions({
previewOptions: {
...previewOptions,
...input,
},
});
}
return (
<Form.Group>
<Form.Group>
<Form.Check
id="preview-task"
checked={options.previews ?? false}
label={intl.formatMessage({
id: "dialogs.scene_gen.video_previews",
})}
onChange={() => setOptions({ previews: !options.previews })}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
id="image-preview-task"
checked={options.imagePreviews ?? false}
disabled={!options.previews}
label={intl.formatMessage({
id: "dialogs.scene_gen.image_previews",
})}
onChange={() =>
setOptions({ imagePreviews: !options.imagePreviews })
}
className="ml-2 flex-grow"
/>
</div>
</Form.Group>
<Form.Group>
<Button
onClick={() => setPreviewOptionsOpen(!previewOptionsOpen)}
className="minimal pl-0 no-focus"
>
<Icon icon={previewOptionsOpen ? "chevron-down" : "chevron-right"} />
<span>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_options",
})}
</span>
</Button>
<Form.Group>
<Collapse in={previewOptionsOpen}>
<Form.Group className="mt-2">
<Form.Group id="preview-preset">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_head",
})}
</h6>
<Form.Control
className="w-auto input-control"
as="select"
value={previewOptions.previewPreset ?? GQL.PreviewPreset.Slow}
onChange={(e) =>
setPreviewOptions({
previewPreset: e.currentTarget.value as GQL.PreviewPreset,
})
}
>
{Object.keys(GQL.PreviewPreset).map((p) => (
<option value={p.toLowerCase()} key={p}>
{p}
</option>
))}
</Form.Control>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segments">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={previewOptions.previewSegments?.toString() ?? ""}
onChange={(e) =>
setPreviewOptions({
previewSegments: Number.parseInt(
e.currentTarget.value,
10
),
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segment-duration">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={
previewOptions.previewSegmentDuration?.toString() ?? ""
}
onChange={(e) =>
setPreviewOptions({
previewSegmentDuration: Number.parseFloat(
e.currentTarget.value
),
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
value={previewOptions.previewExcludeStart ?? ""}
onChange={(e) =>
setPreviewOptions({
previewExcludeStart: e.currentTarget.value,
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
value={previewOptions.previewExcludeEnd ?? ""}
onChange={(e) =>
setPreviewOptions({
previewExcludeEnd: e.currentTarget.value,
})
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_desc",
})}
</Form.Text>
</Form.Group>
</Form.Group>
</Collapse>
</Form.Group>
</Form.Group>
<Form.Group>
<Form.Check
id="sprite-task"
checked={options.sprites ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
onChange={() => setOptions({ sprites: !options.sprites })}
/>
<Form.Group>
<Form.Check
id="marker-task"
checked={options.markers ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setOptions({ markers: !options.markers })}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Group>
<Form.Check
id="marker-image-preview-task"
checked={options.markerImagePreviews ?? false}
disabled={!options.markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_image_previews",
})}
onChange={() =>
setOptions({
markerImagePreviews: !options.markerImagePreviews,
})
}
className="ml-2 flex-grow"
/>
<Form.Check
id="marker-screenshot-task"
checked={options.markerScreenshots ?? false}
disabled={!options.markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_screenshots",
})}
onChange={() =>
setOptions({ markerScreenshots: !options.markerScreenshots })
}
className="ml-2 flex-grow"
/>
</Form.Group>
</div>
</Form.Group>
<Form.Group>
<Form.Check
id="transcode-task"
checked={options.transcodes ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
onChange={() => setOptions({ transcodes: !options.transcodes })}
/>
<Form.Check
id="phash-task"
checked={options.phashes ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
onChange={() => setOptions({ phashes: !options.phashes })}
/>
</Form.Group>
<hr />
<Form.Group>
<Form.Check
id="overwrite"
checked={options.overwrite ?? false}
label={intl.formatMessage({ id: "dialogs.scene_gen.overwrite" })}
onChange={() => setOptions({ overwrite: !options.overwrite })}
/>
</Form.Group>
</Form.Group>
</Form.Group>
);
};
interface ISceneGenerateDialog {
selectedIds?: string[];
onClose: () => void;
}
export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
selectedIds,
onClose,
}) => {
const { configuration } = React.useContext(ConfigurationContext);
const [configureDefaults] = useConfigureDefaults();
function getDefaultOptions(): GQL.GenerateMetadataInput {
return {
sprites: true,
phashes: true,
previews: true,
markers: true,
previewOptions: {
previewSegments: 0,
previewSegmentDuration: 0,
previewPreset: GQL.PreviewPreset.Slow,
},
};
}
const [options, setOptions] = useState<GQL.GenerateMetadataInput>(
getDefaultOptions()
);
const [configRead, setConfigRead] = useState(false);
const [paths /* , setPaths */] = useState<string[]>([]);
const [showManual, setShowManual] = useState(false);
// const [settingPaths, setSettingPaths] = useState(false);
const [savingDefaults, setSavingDefaults] = useState(false);
const [animation, setAnimation] = useState(true);
const intl = useIntl();
const Toast = useToast();
useEffect(() => {
if (configRead) {
return;
}
if (configuration?.defaults.generate) {
const { generate } = configuration.defaults;
setOptions(withoutTypename(generate));
setConfigRead(true);
} else if (configuration?.general) {
// backwards compatibility
const { general } = configuration;
setOptions((existing) => ({
...existing,
previewOptions: {
...existing.previewOptions,
previewSegments:
general.previewSegments ?? existing.previewOptions?.previewSegments,
previewSegmentDuration:
general.previewSegmentDuration ??
existing.previewOptions?.previewSegmentDuration,
previewExcludeStart:
general.previewExcludeStart ??
existing.previewOptions?.previewExcludeStart,
previewExcludeEnd:
general.previewExcludeEnd ??
existing.previewOptions?.previewExcludeEnd,
previewPreset:
general.previewPreset ?? existing.previewOptions?.previewPreset,
},
}));
setConfigRead(true);
}
}, [configuration, configRead]);
const selectionStatus = useMemo(() => {
if (selectedIds) {
return (
<Form.Group id="selected-generate-ids">
<FormattedMessage
id="config.tasks.generate.generating_scenes"
values={{
num: selectedIds.length,
scene: intl.formatMessage(
{
id: "countables.scenes",
},
{
count: selectedIds.length,
}
),
}}
/>
.
</Form.Group>
);
}
const message = paths.length ? (
<div>
<FormattedMessage id="config.tasks.generate.generating_from_paths" />:
<ul>
{paths.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</div>
) : (
<span>
<FormattedMessage
id="config.tasks.generate.generating_scenes"
values={{
num: intl.formatMessage({ id: "all" }),
scene: intl.formatMessage(
{
id: "countables.scenes",
},
{
count: 0,
}
),
}}
/>
.
</span>
);
// function onClick() {
// setAnimation(false);
// setSettingPaths(true);
// }
return (
<Form.Group className="dialog-selected-folders">
<div>
{message}
{/* <div>
<Button
title={intl.formatMessage({ id: "actions.select_folders" })}
onClick={() => onClick()}
>
<Icon icon="folder-open" />
</Button>
</div> */}
</div>
</Form.Group>
);
}, [selectedIds, intl, paths]);
async function onGenerate() {
try {
await mutateMetadataGenerate(options);
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.generate" }) }
),
});
} catch (e) {
Toast.error(e);
} finally {
onClose();
}
}
function makeDefaultGenerateInput() {
const ret = options;
// const { paths: _paths, ...withoutSpecifics } = ret;
const { overwrite: _overwrite, ...withoutSpecifics } = ret;
return withoutSpecifics;
}
function onShowManual() {
setAnimation(false);
setShowManual(true);
}
async function setAsDefault() {
try {
setSavingDefaults(true);
await configureDefaults({
variables: {
input: {
generate: makeDefaultGenerateInput(),
},
},
});
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.defaults_set" },
{ action: intl.formatMessage({ id: "actions.generate" }) }
),
});
} catch (e) {
Toast.error(e);
} finally {
setSavingDefaults(false);
}
}
// if (settingPaths) {
// return (
// <DirectorySelectionDialog
// animation={false}
// allowEmpty
// initialPaths={paths}
// onClose={(p) => {
// if (p) {
// setPaths(p);
// }
// setSettingPaths(false);
// }}
// />
// );
// }
if (showManual) {
return (
<Manual
animation={false}
show
onClose={() => setShowManual(false)}
defaultActiveTab="Tasks.md"
/>
);
}
return (
<Modal
show
modalProps={{ animation, size: "lg" }}
icon="cogs"
header={intl.formatMessage({ id: "actions.generate" })}
accept={{
onClick: onGenerate,
text: intl.formatMessage({ id: "actions.generate" }),
}}
cancel={{
onClick: () => onClose(),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
disabled={savingDefaults}
footerButtons={
<OperationButton variant="secondary" operation={setAsDefault}>
<FormattedMessage id="actions.set_as_default" />
</OperationButton>
}
leftFooterButtons={
<Button
title="Help"
className="minimal help-button"
onClick={() => onShowManual()}
>
<Icon icon="question-circle" />
</Button>
}
>
<Form>
{selectionStatus}
<GenerateOptions options={options} setOptions={setOptions} />
</Form>
</Modal>
);
};

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { Button, Form, Spinner } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { import {
mutateMetadataIdentify, mutateMetadataIdentify,
useConfiguration, useConfiguration,
useConfigureDefaults, useConfigureDefaults,
useListSceneScrapers, useListSceneScrapers,
} from "src/core/StashService"; } from "src/core/StashService";
import { Icon, Modal } from "src/components/Shared"; import { Icon, Modal, OperationButton } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
@@ -159,7 +159,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
} }
return ( return (
<Form.Group id="selected-identify-folders"> <Form.Group className="dialog-selected-folders">
<div> <div>
{message} {message}
<div> <div>
@@ -346,6 +346,13 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
}, },
}, },
}); });
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.defaults_set" },
{ action: intl.formatMessage({ id: "actions.identify" }) }
),
});
} catch (e) { } catch (e) {
Toast.error(e); Toast.error(e);
} finally { } finally {
@@ -409,16 +416,13 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
}} }}
disabled={editingField || savingDefaults || sources.length === 0} disabled={editingField || savingDefaults || sources.length === 0}
footerButtons={ footerButtons={
<Button <OperationButton
variant="secondary" variant="secondary"
disabled={editingField || savingDefaults} disabled={editingField || savingDefaults}
onClick={() => setAsDefault()} operation={setAsDefault}
> >
{savingDefaults && (
<Spinner animation="border" role="status" size="sm" />
)}
<FormattedMessage id="actions.set_as_default" /> <FormattedMessage id="actions.set_as_default" />
</Button> </OperationButton>
} }
leftFooterButtons={ leftFooterButtons={
<Button <Button

View File

@@ -36,10 +36,3 @@
.field-options-table td:first-child { .field-options-table td:first-child {
padding-left: 0.75rem; padding-left: 0.75rem;
} }
#selected-identify-folders {
& > div {
display: flex;
justify-content: space-between;
}
}

View File

@@ -0,0 +1,108 @@
import React from "react";
import { Form } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { useIntl } from "react-intl";
interface IScanOptions {
options: GQL.ScanMetadataInput;
setOptions: (s: GQL.ScanMetadataInput) => void;
}
export const ScanOptions: React.FC<IScanOptions> = ({
options,
setOptions: setOptionsState,
}) => {
const intl = useIntl();
const {
useFileMetadata,
stripFileExtension,
scanGeneratePreviews,
scanGenerateImagePreviews,
scanGenerateSprites,
scanGeneratePhashes,
scanGenerateThumbnails,
} = options;
function setOptions(input: Partial<GQL.ScanMetadataInput>) {
setOptionsState({ ...options, ...input });
}
return (
<Form.Group>
<Form.Check
id="use-file-metadata"
checked={useFileMetadata ?? false}
label={intl.formatMessage({
id: "config.tasks.set_name_date_details_from_metadata_if_present",
})}
onChange={() => setOptions({ useFileMetadata: !useFileMetadata })}
/>
<Form.Check
id="strip-file-extension"
checked={stripFileExtension ?? false}
label={intl.formatMessage({
id: "config.tasks.dont_include_file_extension_as_part_of_the_title",
})}
onChange={() => setOptions({ stripFileExtension: !stripFileExtension })}
/>
<Form.Check
id="scan-generate-previews"
checked={scanGeneratePreviews ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_video_previews_during_scan",
})}
onChange={() =>
setOptions({ scanGeneratePreviews: !scanGeneratePreviews })
}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
id="scan-generate-image-previews"
checked={scanGenerateImagePreviews ?? false}
disabled={!scanGeneratePreviews}
label={intl.formatMessage({
id: "config.tasks.generate_previews_during_scan",
})}
onChange={() =>
setOptions({
scanGenerateImagePreviews: !scanGenerateImagePreviews,
})
}
className="ml-2 flex-grow"
/>
</div>
<Form.Check
id="scan-generate-sprites"
checked={scanGenerateSprites ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_sprites_during_scan",
})}
onChange={() =>
setOptions({ scanGenerateSprites: !scanGenerateSprites })
}
/>
<Form.Check
id="scan-generate-phashes"
checked={scanGeneratePhashes ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_phashes_during_scan",
})}
onChange={() =>
setOptions({ scanGeneratePhashes: !scanGeneratePhashes })
}
/>
<Form.Check
id="scan-generate-thumbnails"
checked={scanGenerateThumbnails ?? false}
label={intl.formatMessage({
id: "config.tasks.generate_thumbnails_during_scan",
})}
onChange={() =>
setOptions({ scanGenerateThumbnails: !scanGenerateThumbnails })
}
/>
</Form.Group>
);
};

View File

@@ -0,0 +1,209 @@
import React, { useState, useMemo, useEffect } from "react";
import { Button, Form } from "react-bootstrap";
import {
mutateMetadataScan,
useConfigureDefaults,
} from "src/core/StashService";
import { Icon, Modal, OperationButton } from "src/components/Shared";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { FormattedMessage, useIntl } from "react-intl";
import { DirectorySelectionDialog } from "src/components/Settings/SettingsTasksPanel/DirectorySelectionDialog";
import { Manual } from "src/components/Help/Manual";
import { ScanOptions } from "./Options";
import { withoutTypename } from "src/utils";
import { ConfigurationContext } from "src/hooks/Config";
interface IScanDialogProps {
onClose: () => void;
}
export const ScanDialog: React.FC<IScanDialogProps> = ({ onClose }) => {
const [configureDefaults] = useConfigureDefaults();
const [options, setOptions] = useState<GQL.ScanMetadataInput>({});
const [paths, setPaths] = useState<string[]>([]);
const [showManual, setShowManual] = useState(false);
const [settingPaths, setSettingPaths] = useState(false);
const [animation, setAnimation] = useState(true);
const [savingDefaults, setSavingDefaults] = useState(false);
const intl = useIntl();
const Toast = useToast();
const { configuration } = React.useContext(ConfigurationContext);
useEffect(() => {
if (!configuration?.defaults) {
return;
}
const { scan } = configuration.defaults;
if (scan) {
setOptions(withoutTypename(scan));
}
}, [configuration]);
const selectionStatus = useMemo(() => {
const message = paths.length ? (
<div>
<FormattedMessage id="config.tasks.scan.scanning_paths" />:
<ul>
{paths.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</div>
) : (
<span>
<FormattedMessage id="config.tasks.scan.scanning_all_paths" />.
</span>
);
function onClick() {
setAnimation(false);
setSettingPaths(true);
}
return (
<Form.Group className="dialog-selected-folders">
<div>
{message}
<div>
<Button
title={intl.formatMessage({ id: "actions.select_folders" })}
onClick={() => onClick()}
>
<Icon icon="folder-open" />
</Button>
</div>
</div>
</Form.Group>
);
}, [intl, paths]);
if (!configuration) return <div />;
function makeDefaultScanInput() {
const ret = options;
const { paths: _paths, ...withoutSpecifics } = ret;
return withoutSpecifics;
}
async function onScan() {
try {
await mutateMetadataScan({
...options,
paths: paths.length ? paths : undefined,
});
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.scan" }) }
),
});
} catch (e) {
Toast.error(e);
} finally {
onClose();
}
}
function onShowManual() {
setAnimation(false);
setShowManual(true);
}
async function setAsDefault() {
try {
setSavingDefaults(true);
await configureDefaults({
variables: {
input: {
scan: makeDefaultScanInput(),
},
},
});
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.defaults_set" },
{ action: intl.formatMessage({ id: "actions.scan" }) }
),
});
} catch (e) {
Toast.error(e);
} finally {
setSavingDefaults(false);
}
}
if (settingPaths) {
return (
<DirectorySelectionDialog
animation={false}
allowEmpty
initialPaths={paths}
onClose={(p) => {
if (p) {
setPaths(p);
}
setSettingPaths(false);
}}
/>
);
}
if (showManual) {
return (
<Manual
animation={false}
show
onClose={() => setShowManual(false)}
defaultActiveTab="Tasks.md"
/>
);
}
return (
<Modal
modalProps={{ animation, size: "lg" }}
show
icon="cogs"
header={intl.formatMessage({ id: "actions.scan" })}
accept={{
onClick: onScan,
text: intl.formatMessage({ id: "actions.scan" }),
}}
cancel={{
onClick: () => onClose(),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
disabled={savingDefaults}
footerButtons={
<OperationButton variant="secondary" operation={setAsDefault}>
<FormattedMessage id="actions.set_as_default" />
</OperationButton>
}
leftFooterButtons={
<Button
title="Help"
className="minimal help-button"
onClick={() => onShowManual()}
>
<Icon icon="question-circle" />
</Button>
}
>
<Form>
{selectionStatus}
<ScanOptions options={options} setOptions={(o) => setOptions(o)} />
</Form>
</Modal>
);
};
export default ScanDialog;

View File

@@ -0,0 +1,8 @@
@import "IdentifyDialog/styles.scss";
.dialog-selected-folders {
& > div {
display: flex;
justify-content: space-between;
}
}

View File

@@ -12,7 +12,7 @@
&-header, &-header,
&-body { &-body {
background-color: #30404d; background-color: $card-bg;
color: $text-color; color: $text-color;
overflow-y: hidden; overflow-y: hidden;
} }

View File

@@ -35,7 +35,7 @@ import { ExternalPlayerButton } from "./ExternalPlayerButton";
import { SceneMoviePanel } from "./SceneMoviePanel"; import { SceneMoviePanel } from "./SceneMoviePanel";
import { SceneGalleriesPanel } from "./SceneGalleriesPanel"; import { SceneGalleriesPanel } from "./SceneGalleriesPanel";
import { DeleteScenesDialog } from "../DeleteScenesDialog"; import { DeleteScenesDialog } from "../DeleteScenesDialog";
import { SceneGenerateDialog } from "../SceneGenerateDialog"; import { GenerateDialog } from "../../Dialogs/GenerateDialog";
import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel"; import { SceneVideoFilterPanel } from "./SceneVideoFilterPanel";
import { OrganizedButton } from "./OrganizedButton"; import { OrganizedButton } from "./OrganizedButton";
@@ -332,7 +332,7 @@ const ScenePage: React.FC<IProps> = ({ scene, refetch }) => {
function maybeRenderSceneGenerateDialog() { function maybeRenderSceneGenerateDialog() {
if (isGenerateDialogOpen) { if (isGenerateDialogOpen) {
return ( return (
<SceneGenerateDialog <GenerateDialog
selectedIds={[scene.id]} selectedIds={[scene.id]}
onClose={() => { onClose={() => {
setIsGenerateDialogOpen(false); setIsGenerateDialogOpen(false);

View File

@@ -1,322 +0,0 @@
import React, { useState, useEffect } from "react";
import { Form, Button, Collapse } from "react-bootstrap";
import { mutateMetadataGenerate } from "src/core/StashService";
import { Modal, Icon } from "src/components/Shared";
import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql";
import { useIntl } from "react-intl";
import { ConfigurationContext } from "src/hooks/Config";
interface ISceneGenerateDialogProps {
selectedIds: string[];
onClose: () => void;
}
export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
props: ISceneGenerateDialogProps
) => {
const { configuration } = React.useContext(ConfigurationContext);
const [sprites, setSprites] = useState(true);
const [phashes, setPhashes] = useState(true);
const [previews, setPreviews] = useState(true);
const [markers, setMarkers] = useState(true);
const [transcodes, setTranscodes] = useState(false);
const [overwrite, setOverwrite] = useState(true);
const [imagePreviews, setImagePreviews] = useState(false);
const [previewSegments, setPreviewSegments] = useState<number>(0);
const [previewSegmentDuration, setPreviewSegmentDuration] = useState<number>(
0
);
const [previewExcludeStart, setPreviewExcludeStart] = useState<
string | undefined
>(undefined);
const [previewExcludeEnd, setPreviewExcludeEnd] = useState<
string | undefined
>(undefined);
const [previewPreset, setPreviewPreset] = useState<string>(
GQL.PreviewPreset.Slow
);
const [markerImagePreviews, setMarkerImagePreviews] = useState(false);
const [markerScreenshots, setMarkerScreenshots] = useState(false);
const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false);
const intl = useIntl();
const Toast = useToast();
useEffect(() => {
if (!configuration) return;
if (configuration.general) {
setPreviewSegments(configuration.general.previewSegments);
setPreviewSegmentDuration(configuration.general.previewSegmentDuration);
setPreviewExcludeStart(configuration.general.previewExcludeStart);
setPreviewExcludeEnd(configuration.general.previewExcludeEnd);
setPreviewPreset(configuration.general.previewPreset);
}
}, [configuration]);
async function onGenerate() {
try {
await mutateMetadataGenerate({
sprites,
phashes,
previews,
imagePreviews: previews && imagePreviews,
markers,
markerImagePreviews: markers && markerImagePreviews,
markerScreenshots: markers && markerScreenshots,
transcodes,
overwrite,
sceneIDs: props.selectedIds,
previewOptions: {
previewPreset: (previewPreset as GQL.PreviewPreset) ?? undefined,
previewSegments,
previewSegmentDuration,
previewExcludeStart,
previewExcludeEnd,
},
});
Toast.success({
content: intl.formatMessage({ id: "toast.started_generating" }),
});
} catch (e) {
Toast.error(e);
} finally {
props.onClose();
}
}
return (
<Modal
show
icon="cogs"
header={intl.formatMessage({ id: "actions.generate" })}
accept={{
onClick: onGenerate,
text: intl.formatMessage({ id: "actions.generate" }),
}}
cancel={{
onClick: () => props.onClose(),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
>
<Form>
<Form.Group>
<Form.Check
id="preview-task"
checked={previews}
label={intl.formatMessage({
id: "dialogs.scene_gen.video_previews",
})}
onChange={() => setPreviews(!previews)}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
id="image-preview-task"
checked={imagePreviews}
disabled={!previews}
label={intl.formatMessage({
id: "dialogs.scene_gen.image_previews",
})}
onChange={() => setImagePreviews(!imagePreviews)}
className="ml-2 flex-grow"
/>
</div>
<div className="my-2">
<Button
onClick={() => setPreviewOptionsOpen(!previewOptionsOpen)}
className="minimal pl-0 no-focus"
>
<Icon
icon={previewOptionsOpen ? "chevron-down" : "chevron-right"}
/>
<span>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_options",
})}
</span>
</Button>
<Collapse in={previewOptionsOpen}>
<div>
<Form.Group id="transcode-size">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_head",
})}
</h6>
<Form.Control
className="w-auto input-control"
as="select"
value={previewPreset}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setPreviewPreset(e.currentTarget.value)
}
>
{Object.keys(GQL.PreviewPreset).map((p) => (
<option value={p.toLowerCase()} key={p}>
{p}
</option>
))}
</Form.Control>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_preset_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segments">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={previewSegments.toString()}
onInput={(e: React.FormEvent<HTMLInputElement>) =>
setPreviewSegments(
Number.parseInt(e.currentTarget.value, 10)
)
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_count_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-segment-duration">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
type="number"
value={previewSegmentDuration.toString()}
onInput={(e: React.FormEvent<HTMLInputElement>) =>
setPreviewSegmentDuration(
Number.parseFloat(e.currentTarget.value)
)
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_seg_duration_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={previewExcludeStart}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPreviewExcludeStart(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_start_time_desc",
})}
</Form.Text>
</Form.Group>
<Form.Group id="preview-exclude-start">
<h6>
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_head",
})}
</h6>
<Form.Control
className="col col-sm-6 text-input"
defaultValue={previewExcludeEnd}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPreviewExcludeEnd(e.currentTarget.value)
}
/>
<Form.Text className="text-muted">
{intl.formatMessage({
id: "dialogs.scene_gen.preview_exclude_end_time_desc",
})}
</Form.Text>
</Form.Group>
</div>
</Collapse>
</div>
<Form.Check
id="sprite-task"
checked={sprites}
label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
onChange={() => setSprites(!sprites)}
/>
<Form.Check
id="marker-task"
checked={markers}
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setMarkers(!markers)}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Group>
<Form.Check
id="marker-image-preview-task"
checked={markerImagePreviews}
disabled={!markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_image_previews",
})}
onChange={() => setMarkerImagePreviews(!markerImagePreviews)}
className="ml-2 flex-grow"
/>
<Form.Check
id="marker-screenshot-task"
checked={markerScreenshots}
disabled={!markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_screenshots",
})}
onChange={() => setMarkerScreenshots(!markerScreenshots)}
className="ml-2 flex-grow"
/>
</Form.Group>
</div>
<Form.Check
id="transcode-task"
checked={transcodes}
label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
onChange={() => setTranscodes(!transcodes)}
/>
<Form.Check
id="phash-task"
checked={phashes}
label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
onChange={() => setPhashes(!phashes)}
/>
<hr />
<Form.Check
id="overwrite"
checked={overwrite}
label={intl.formatMessage({ id: "dialogs.scene_gen.overwrite" })}
onChange={() => setOverwrite(!overwrite)}
/>
</Form.Group>
</Form>
</Modal>
);
};

View File

@@ -19,7 +19,7 @@ import { WallPanel } from "../Wall/WallPanel";
import { SceneListTable } from "./SceneListTable"; import { SceneListTable } from "./SceneListTable";
import { EditScenesDialog } from "./EditScenesDialog"; import { EditScenesDialog } from "./EditScenesDialog";
import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog";
import { SceneGenerateDialog } from "./SceneGenerateDialog"; import { GenerateDialog } from "../Dialogs/GenerateDialog";
import { ExportDialog } from "../Shared/ExportDialog"; import { ExportDialog } from "../Shared/ExportDialog";
import { SceneCardsGrid } from "./SceneCardsGrid"; import { SceneCardsGrid } from "./SceneCardsGrid";
import { TaggerContext } from "../Tagger/context"; import { TaggerContext } from "../Tagger/context";
@@ -180,7 +180,7 @@ export const SceneList: React.FC<ISceneList> = ({
if (isGenerateDialogOpen) { if (isGenerateDialogOpen) {
return ( return (
<> <>
<SceneGenerateDialog <GenerateDialog
selectedIds={Array.from(selectedIds.values())} selectedIds={Array.from(selectedIds.values())}
onClose={() => { onClose={() => {
setIsGenerateDialogOpen(false); setIsGenerateDialogOpen(false);

View File

@@ -1,128 +0,0 @@
import React, { useState } from "react";
import { Button, Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { mutateMetadataGenerate } from "src/core/StashService";
import { useToast } from "src/hooks";
export const GenerateButton: React.FC = () => {
const Toast = useToast();
const intl = useIntl();
const [sprites, setSprites] = useState(true);
const [phashes, setPhashes] = useState(true);
const [previews, setPreviews] = useState(true);
const [markers, setMarkers] = useState(true);
const [transcodes, setTranscodes] = useState(false);
const [imagePreviews, setImagePreviews] = useState(false);
const [markerImagePreviews, setMarkerImagePreviews] = useState(false);
const [markerScreenshots, setMarkerScreenshots] = useState(false);
async function onGenerate() {
try {
await mutateMetadataGenerate({
sprites,
phashes,
previews,
imagePreviews: previews && imagePreviews,
markers,
markerImagePreviews: markers && markerImagePreviews,
markerScreenshots: markers && markerScreenshots,
transcodes,
});
Toast.success({
content: intl.formatMessage({
id: "toast.added_generation_job_to_queue",
}),
});
} catch (e) {
Toast.error(e);
}
}
return (
<>
<Form.Group>
<Form.Check
id="preview-task"
checked={previews}
label={intl.formatMessage({ id: "dialogs.scene_gen.video_previews" })}
onChange={() => setPreviews(!previews)}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
id="image-preview-task"
checked={imagePreviews}
disabled={!previews}
label={intl.formatMessage({
id: "dialogs.scene_gen.image_previews",
})}
onChange={() => setImagePreviews(!imagePreviews)}
className="ml-2 flex-grow"
/>
</div>
<Form.Check
id="sprite-task"
checked={sprites}
label={intl.formatMessage({ id: "dialogs.scene_gen.sprites" })}
onChange={() => setSprites(!sprites)}
/>
<Form.Check
id="marker-task"
checked={markers}
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setMarkers(!markers)}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Group>
<Form.Check
id="marker-image-preview-task"
checked={markerImagePreviews}
disabled={!markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_image_previews",
})}
onChange={() => setMarkerImagePreviews(!markerImagePreviews)}
className="ml-2 flex-grow"
/>
<Form.Check
id="marker-screenshot-task"
checked={markerScreenshots}
disabled={!markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_screenshots",
})}
onChange={() => setMarkerScreenshots(!markerScreenshots)}
className="ml-2 flex-grow"
/>
</Form.Group>
</div>
<Form.Check
id="transcode-task"
checked={transcodes}
label={intl.formatMessage({ id: "dialogs.scene_gen.transcodes" })}
onChange={() => setTranscodes(!transcodes)}
/>
<Form.Check
id="phash-task"
checked={phashes}
label={intl.formatMessage({ id: "dialogs.scene_gen.phash" })}
onChange={() => setPhashes(!phashes)}
/>
</Form.Group>
<Form.Group>
<Button
id="generate"
variant="secondary"
type="submit"
onClick={() => onGenerate()}
>
<FormattedMessage id="actions.generate" />
</Button>
<Form.Text className="text-muted">
{intl.formatMessage({ id: "config.tasks.generate_desc" })}
</Form.Text>
</Form.Group>
</>
);
};

View File

@@ -1,69 +1,69 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form } from "react-bootstrap"; import { Button, ButtonGroup, Card, Form } from "react-bootstrap";
import { import {
mutateMetadataImport, mutateMetadataImport,
mutateMetadataClean,
mutateMetadataScan,
mutateMetadataAutoTag,
mutateMetadataExport, mutateMetadataExport,
mutateMigrateHashNaming, mutateMigrateHashNaming,
usePlugins, usePlugins,
mutateRunPluginTask, mutateRunPluginTask,
mutateBackupDatabase, mutateBackupDatabase,
mutateMetadataScan,
mutateMetadataIdentify,
mutateMetadataAutoTag,
mutateMetadataGenerate,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { LoadingIndicator, Modal } from "src/components/Shared"; import { LoadingIndicator, Modal } from "src/components/Shared";
import { downloadFile } from "src/utils"; import { downloadFile, withoutTypename } from "src/utils";
import IdentifyDialog from "src/components/Dialogs/IdentifyDialog/IdentifyDialog"; import IdentifyDialog from "src/components/Dialogs/IdentifyDialog/IdentifyDialog";
import { GenerateButton } from "./GenerateButton";
import { ImportDialog } from "./ImportDialog"; import { ImportDialog } from "./ImportDialog";
import { DirectorySelectionDialog } from "./DirectorySelectionDialog";
import { JobTable } from "./JobTable"; import { JobTable } from "./JobTable";
import ScanDialog from "src/components/Dialogs/ScanDialog/ScanDialog";
import AutoTagDialog from "src/components/Dialogs/AutoTagDialog";
import { GenerateDialog } from "src/components/Dialogs/GenerateDialog";
import CleanDialog from "src/components/Dialogs/CleanDialog";
import { ConfigurationContext } from "src/hooks/Config";
import { PropsWithChildren } from "react-router/node_modules/@types/react";
type Plugin = Pick<GQL.Plugin, "id">; type Plugin = Pick<GQL.Plugin, "id">;
type PluginTask = Pick<GQL.PluginTask, "name" | "description">; type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
interface ITask {
description?: React.ReactNode;
}
const Task: React.FC<PropsWithChildren<ITask>> = ({
children,
description,
}) => (
<div className="task">
{children}
{description ? (
<Form.Text className="text-muted">{description}</Form.Text>
) : undefined}
</div>
);
export const SettingsTasksPanel: React.FC = () => { export const SettingsTasksPanel: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); const Toast = useToast();
const [dialogOpen, setDialogOpenState] = useState({ const [dialogOpen, setDialogOpenState] = useState({
importAlert: false, importAlert: false,
cleanAlert: false,
import: false, import: false,
clean: false, clean: false,
scan: false, scan: false,
autoTag: false, autoTag: false,
identify: false, identify: false,
generate: false,
}); });
type DialogOpenState = typeof dialogOpen; type DialogOpenState = typeof dialogOpen;
const [isBackupRunning, setIsBackupRunning] = useState<boolean>(false); const [isBackupRunning, setIsBackupRunning] = useState<boolean>(false);
const [useFileMetadata, setUseFileMetadata] = useState<boolean>(false);
const [stripFileExtension, setStripFileExtension] = useState<boolean>(false);
const [scanGeneratePreviews, setScanGeneratePreviews] = useState<boolean>(
false
);
const [scanGenerateSprites, setScanGenerateSprites] = useState<boolean>(
false
);
const [scanGeneratePhashes, setScanGeneratePhashes] = useState<boolean>(
false
);
const [scanGenerateThumbnails, setScanGenerateThumbnails] = useState<boolean>(
false
);
const [cleanDryRun, setCleanDryRun] = useState<boolean>(false);
const [
scanGenerateImagePreviews,
setScanGenerateImagePreviews,
] = useState<boolean>(false);
const [autoTagPerformers, setAutoTagPerformers] = useState<boolean>(true); const { configuration } = React.useContext(ConfigurationContext);
const [autoTagStudios, setAutoTagStudios] = useState<boolean>(true);
const [autoTagTags, setAutoTagTags] = useState<boolean>(true);
const plugins = usePlugins(); const plugins = usePlugins();
@@ -105,41 +105,12 @@ export const SettingsTasksPanel: React.FC = () => {
); );
} }
function onClean() { function renderCleanDialog() {
setDialogOpen({ cleanAlert: false }); if (!dialogOpen.clean) {
mutateMetadataClean({ return;
dryRun: cleanDryRun,
});
} }
function renderCleanAlert() { return <CleanDialog onClose={() => setDialogOpen({ clean: false })} />;
let msg;
if (cleanDryRun) {
msg = (
<p>{intl.formatMessage({ id: "actions.tasks.dry_mode_selected" })}</p>
);
} else {
msg = (
<p>
{intl.formatMessage({ id: "actions.tasks.clean_confirm_message" })}
</p>
);
}
return (
<Modal
show={dialogOpen.cleanAlert}
icon="trash-alt"
accept={{
text: intl.formatMessage({ id: "actions.clean" }),
variant: "danger",
onClick: onClean,
}}
cancel={{ onClick: () => setDialogOpen({ cleanAlert: false }) }}
>
{msg}
</Modal>
);
} }
function renderImportDialog() { function renderImportDialog() {
@@ -155,38 +126,7 @@ export const SettingsTasksPanel: React.FC = () => {
return; return;
} }
return <DirectorySelectionDialog onClose={onScanDialogClosed} />; return <ScanDialog onClose={() => setDialogOpen({ scan: false })} />;
}
function onScanDialogClosed(paths?: string[]) {
if (paths) {
onScan(paths);
}
setDialogOpen({ scan: false });
}
async function onScan(paths?: string[]) {
try {
await mutateMetadataScan({
paths,
useFileMetadata,
stripFileExtension,
scanGeneratePreviews,
scanGenerateImagePreviews,
scanGenerateSprites,
scanGeneratePhashes,
scanGenerateThumbnails,
});
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.scan" }) }
),
});
} catch (e) {
Toast.error(e);
}
} }
function renderAutoTagDialog() { function renderAutoTagDialog() {
@@ -194,7 +134,7 @@ export const SettingsTasksPanel: React.FC = () => {
return; return;
} }
return <DirectorySelectionDialog onClose={onAutoTagDialogClosed} />; return <AutoTagDialog onClose={() => setDialogOpen({ autoTag: false })} />;
} }
function maybeRenderIdentifyDialog() { function maybeRenderIdentifyDialog() {
@@ -205,36 +145,12 @@ export const SettingsTasksPanel: React.FC = () => {
); );
} }
function onAutoTagDialogClosed(paths?: string[]) { function maybeRenderGenerateDialog() {
if (paths) { if (!dialogOpen.generate) return;
onAutoTag(paths);
}
setDialogOpen({ autoTag: false }); return (
} <GenerateDialog onClose={() => setDialogOpen({ generate: false })} />
);
function getAutoTagInput(paths?: string[]) {
const wildcard = ["*"];
return {
paths,
performers: autoTagPerformers ? wildcard : [],
studios: autoTagStudios ? wildcard : [],
tags: autoTagTags ? wildcard : [],
};
}
async function onAutoTag(paths?: string[]) {
try {
await mutateMetadataAutoTag(getAutoTagInput(paths));
Toast.success({
content: intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{ operation_name: intl.formatMessage({ id: "actions.auto_tag" }) }
),
});
} catch (e) {
Toast.error(e);
}
} }
async function onPluginTaskClicked(plugin: Plugin, operation: PluginTask) { async function onPluginTaskClicked(plugin: Plugin, operation: PluginTask) {
@@ -254,19 +170,15 @@ export const SettingsTasksPanel: React.FC = () => {
return pluginTasks.map((o) => { return pluginTasks.map((o) => {
return ( return (
<div key={o.name}> <Task description={o.description} key={o.name}>
<Button <Button
onClick={() => onPluginTaskClicked(plugin, o)} onClick={() => onPluginTaskClicked(plugin, o)}
className="mt-3"
variant="secondary" variant="secondary"
size="sm" size="sm"
> >
{o.name} {o.name}
</Button> </Button>
{o.description ? ( </Task>
<Form.Text className="text-muted">{o.description}</Form.Text>
) : undefined}
</div>
); );
}); });
} }
@@ -302,16 +214,20 @@ export const SettingsTasksPanel: React.FC = () => {
return ( return (
<> <>
<hr /> <hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}</h5> <h5>{intl.formatMessage({ id: "config.tasks.plugin_tasks" })}</h5>
{taskPlugins.map((o) => { {taskPlugins.map((o) => {
return ( return (
<div key={`${o.id}`} className="mb-3"> <Form.Group key={`${o.id}`}>
<h6>{o.name}</h6> <h6>{o.name}</h6>
<Card className="task-group">
{renderPluginTasks(o, o.tasks ?? [])} {renderPluginTasks(o, o.tasks ?? [])}
<hr /> </Card>
</div> </Form.Group>
); );
})} })}
</Form.Group>
</> </>
); );
} }
@@ -348,6 +264,66 @@ export const SettingsTasksPanel: React.FC = () => {
} }
} }
async function onScanClicked() {
// check if defaults are set for scan
// if not, then open the dialog
if (!configuration) {
return;
}
const { scan } = configuration?.defaults;
if (!scan) {
setDialogOpen({ scan: true });
} else {
mutateMetadataScan(withoutTypename(scan));
}
}
async function onIdentifyClicked() {
// check if defaults are set for identify
// if not, then open the dialog
if (!configuration) {
return;
}
const { identify } = configuration?.defaults;
if (!identify) {
setDialogOpen({ identify: true });
} else {
mutateMetadataIdentify(withoutTypename(identify));
}
}
async function onAutoTagClicked() {
// check if defaults are set for auto tag
// if not, then open the dialog
if (!configuration) {
return;
}
const { autoTag } = configuration?.defaults;
if (!autoTag) {
setDialogOpen({ autoTag: true });
} else {
mutateMetadataAutoTag(withoutTypename(autoTag));
}
}
async function onGenerateClicked() {
// check if defaults are set for generate
// if not, then open the dialog
if (!configuration) {
return;
}
const { generate } = configuration?.defaults;
if (!generate) {
setDialogOpen({ generate: true });
} else {
mutateMetadataGenerate(withoutTypename(generate));
}
}
if (isBackupRunning) { if (isBackupRunning) {
return ( return (
<LoadingIndicator <LoadingIndicator
@@ -359,11 +335,12 @@ export const SettingsTasksPanel: React.FC = () => {
return ( return (
<> <>
{renderImportAlert()} {renderImportAlert()}
{renderCleanAlert()} {renderCleanDialog()}
{renderImportDialog()} {renderImportDialog()}
{renderScanDialog()} {renderScanDialog()}
{renderAutoTagDialog()} {renderAutoTagDialog()}
{maybeRenderIdentifyDialog()} {maybeRenderIdentifyDialog()}
{maybeRenderGenerateDialog()}
<h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4> <h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4>
@@ -373,191 +350,129 @@ export const SettingsTasksPanel: React.FC = () => {
<Form.Group> <Form.Group>
<h5>{intl.formatMessage({ id: "library" })}</h5> <h5>{intl.formatMessage({ id: "library" })}</h5>
<Form.Group> <Card className="task-group">
<h6>{intl.formatMessage({ id: "actions.scan" })}</h6> <Task
<Form.Check description={intl.formatMessage({
id="use-file-metadata" id: "config.tasks.scan_for_content_desc",
checked={useFileMetadata}
label={intl.formatMessage({
id: "config.tasks.set_name_date_details_from_metadata_if_present",
})} })}
onChange={() => setUseFileMetadata(!useFileMetadata)} >
/> <ButtonGroup className="ellipsis-button">
<Form.Check
id="strip-file-extension"
checked={stripFileExtension}
label={intl.formatMessage({
id:
"config.tasks.dont_include_file_extension_as_part_of_the_title",
})}
onChange={() => setStripFileExtension(!stripFileExtension)}
/>
<Form.Check
id="scan-generate-previews"
checked={scanGeneratePreviews}
label={intl.formatMessage({
id: "config.tasks.generate_video_previews_during_scan",
})}
onChange={() => setScanGeneratePreviews(!scanGeneratePreviews)}
/>
<div className="d-flex flex-row">
<div></div>
<Form.Check
id="scan-generate-image-previews"
checked={scanGenerateImagePreviews}
disabled={!scanGeneratePreviews}
label={intl.formatMessage({
id: "config.tasks.generate_previews_during_scan",
})}
onChange={() =>
setScanGenerateImagePreviews(!scanGenerateImagePreviews)
}
className="ml-2 flex-grow"
/>
</div>
<Form.Check
id="scan-generate-sprites"
checked={scanGenerateSprites}
label={intl.formatMessage({
id: "config.tasks.generate_sprites_during_scan",
})}
onChange={() => setScanGenerateSprites(!scanGenerateSprites)}
/>
<Form.Check
id="scan-generate-phashes"
checked={scanGeneratePhashes}
label={intl.formatMessage({
id: "config.tasks.generate_phashes_during_scan",
})}
onChange={() => setScanGeneratePhashes(!scanGeneratePhashes)}
/>
<Form.Check
id="scan-generate-thumbnails"
checked={scanGenerateThumbnails}
label={intl.formatMessage({
id: "config.tasks.generate_thumbnails_during_scan",
})}
onChange={() => setScanGenerateThumbnails(!scanGenerateThumbnails)}
/>
</Form.Group>
<Form.Group>
<Button <Button
className="mr-2"
variant="secondary" variant="secondary"
type="submit" type="submit"
onClick={() => onScan()} onClick={() => onScanClicked()}
> >
<FormattedMessage id="actions.scan" /> <FormattedMessage id="actions.scan" />
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
type="submit"
onClick={() => setDialogOpen({ scan: true })} onClick={() => setDialogOpen({ scan: true })}
> >
<FormattedMessage id="actions.selective_scan" />
</Button> </Button>
<Form.Text className="text-muted"> </ButtonGroup>
{intl.formatMessage({ id: "config.tasks.scan_for_content_desc" })} </Task>
</Form.Text>
</Form.Group>
<Form.Group> <Task
<h6> description={intl.formatMessage({
<FormattedMessage id="config.tasks.identify.heading" /> id: "config.tasks.identify.description",
</h6> })}
>
<ButtonGroup className="ellipsis-button">
<Button <Button
className="mr-2"
variant="secondary" variant="secondary"
type="submit" type="submit"
onClick={() => onIdentifyClicked()}
>
<FormattedMessage id="actions.identify" />
</Button>
<Button
variant="secondary"
onClick={() => setDialogOpen({ identify: true })} onClick={() => setDialogOpen({ identify: true })}
> >
<FormattedMessage id="actions.identify" />
</Button> </Button>
<Form.Text className="text-muted"> </ButtonGroup>
<FormattedMessage id="config.tasks.identify.description" /> </Task>
</Form.Text>
</Form.Group>
<Form.Group> <Task
<h6>{intl.formatMessage({ id: "config.tasks.auto_tagging" })}</h6> description={intl.formatMessage({
id: "config.tasks.auto_tag_based_on_filenames",
<Form.Group> })}
<Form.Check >
id="autotag-performers" <ButtonGroup className="ellipsis-button">
checked={autoTagPerformers}
label={intl.formatMessage({ id: "performers" })}
onChange={() => setAutoTagPerformers(!autoTagPerformers)}
/>
<Form.Check
id="autotag-studios"
checked={autoTagStudios}
label={intl.formatMessage({ id: "studios" })}
onChange={() => setAutoTagStudios(!autoTagStudios)}
/>
<Form.Check
id="autotag-tags"
checked={autoTagTags}
label={intl.formatMessage({ id: "tags" })}
onChange={() => setAutoTagTags(!autoTagTags)}
/>
</Form.Group>
<Form.Group>
<Button <Button
variant="secondary" variant="secondary"
type="submit" type="submit"
className="mr-2" onClick={() => onAutoTagClicked()}
onClick={() => onAutoTag()}
> >
<FormattedMessage id="actions.auto_tag" /> <FormattedMessage id="actions.auto_tag" />
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
type="submit"
onClick={() => setDialogOpen({ autoTag: true })} onClick={() => setDialogOpen({ autoTag: true })}
> >
<FormattedMessage id="actions.selective_auto_tag" />
</Button> </Button>
<Form.Text className="text-muted"> </ButtonGroup>
{intl.formatMessage({ </Task>
id: "config.tasks.auto_tag_based_on_filenames",
<Task
description={intl.formatMessage({
id: "config.tasks.cleanup_desc",
})} })}
</Form.Text>
</Form.Group>
</Form.Group>
</Form.Group>
<hr />
<h5>{intl.formatMessage({ id: "config.tasks.generated_content" })}</h5>
<GenerateButton />
<hr />
<h5>{intl.formatMessage({ id: "config.tasks.maintenance" })}</h5>
<Form.Group>
<Form.Check
id="clean-dryrun"
checked={cleanDryRun}
label={intl.formatMessage({ id: "config.tasks.only_dry_run" })}
onChange={() => setCleanDryRun(!cleanDryRun)}
/>
</Form.Group>
<Form.Group>
<Button
id="clean"
variant="danger"
onClick={() => setDialogOpen({ cleanAlert: true })}
> >
<FormattedMessage id="actions.clean" /> <Button
variant="danger"
type="submit"
onClick={() => setDialogOpen({ clean: true })}
>
<FormattedMessage id="actions.clean" />
</Button> </Button>
<Form.Text className="text-muted"> </Task>
{intl.formatMessage({ id: "config.tasks.cleanup_desc" })} </Card>
</Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<h5>{intl.formatMessage({ id: "metadata" })}</h5>
<Form.Group> <Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.generated_content" })}</h5>
<Card className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.generate_desc",
})}
>
<ButtonGroup className="ellipsis-button">
<Button
variant="secondary"
type="submit"
onClick={() => onGenerateClicked()}
>
<FormattedMessage id="actions.generate" />
</Button>
<Button
variant="secondary"
onClick={() => setDialogOpen({ generate: true })}
>
</Button>
</ButtonGroup>
</Task>
</Card>
</Form.Group>
<hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "metadata" })}</h5>
<Card className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.export_to_json",
})}
>
<Button <Button
id="export" id="export"
variant="secondary" variant="secondary"
@@ -566,51 +481,47 @@ export const SettingsTasksPanel: React.FC = () => {
> >
<FormattedMessage id="actions.full_export" /> <FormattedMessage id="actions.full_export" />
</Button> </Button>
<Form.Text className="text-muted"> </Task>
{intl.formatMessage({ id: "config.tasks.export_to_json" })}
</Form.Text>
</Form.Group>
<Form.Group> <Task
description={intl.formatMessage({
id: "config.tasks.import_from_exported_json",
})}
>
<Button <Button
id="import" id="import"
variant="danger" variant="danger"
type="submit"
onClick={() => setDialogOpen({ importAlert: true })} onClick={() => setDialogOpen({ importAlert: true })}
> >
<FormattedMessage id="actions.full_import" /> <FormattedMessage id="actions.full_import" />
</Button> </Button>
<Form.Text className="text-muted"> </Task>
{intl.formatMessage({ id: "config.tasks.import_from_exported_json" })}
</Form.Text>
</Form.Group>
<Form.Group> <Task
description={intl.formatMessage({
id: "config.tasks.incremental_import",
})}
>
<Button <Button
id="partial-import" id="partial-import"
variant="danger" variant="danger"
type="submit"
onClick={() => setDialogOpen({ import: true })} onClick={() => setDialogOpen({ import: true })}
> >
<FormattedMessage id="actions.import_from_file" /> <FormattedMessage id="actions.import_from_file" />
</Button> </Button>
<Form.Text className="text-muted"> </Task>
{intl.formatMessage({ id: "config.tasks.incremental_import" })} </Card>
</Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
<h5>{intl.formatMessage({ id: "actions.backup" })}</h5>
<Form.Group> <Form.Group>
<Button <h5>{intl.formatMessage({ id: "actions.backup" })}</h5>
id="backup" <Card className="task-group">
variant="secondary" <Task
type="submit" description={intl.formatMessage(
onClick={() => onBackup()}
>
<FormattedMessage id="actions.backup" />
</Button>
<Form.Text className="text-muted">
{intl.formatMessage(
{ id: "config.tasks.backup_database" }, { id: "config.tasks.backup_database" },
{ {
filename_format: ( filename_format: (
@@ -620,10 +531,22 @@ export const SettingsTasksPanel: React.FC = () => {
), ),
} }
)} )}
</Form.Text> >
</Form.Group> <Button
id="backup"
variant="secondary"
type="submit"
onClick={() => onBackup()}
>
<FormattedMessage id="actions.backup" />
</Button>
</Task>
<Form.Group> <Task
description={intl.formatMessage({
id: "config.tasks.backup_and_download",
})}
>
<Button <Button
id="backupDownload" id="backupDownload"
variant="secondary" variant="secondary"
@@ -632,18 +555,23 @@ export const SettingsTasksPanel: React.FC = () => {
> >
<FormattedMessage id="actions.download_backup" /> <FormattedMessage id="actions.download_backup" />
</Button> </Button>
<Form.Text className="text-muted"> </Task>
{intl.formatMessage({ id: "config.tasks.backup_and_download" })} </Card>
</Form.Text>
</Form.Group> </Form.Group>
{renderPlugins()} {renderPlugins()}
<hr /> <hr />
<Form.Group>
<h5>{intl.formatMessage({ id: "config.tasks.migrations" })}</h5> <h5>{intl.formatMessage({ id: "config.tasks.migrations" })}</h5>
<Form.Group> <Card className="task-group">
<Task
description={intl.formatMessage({
id: "config.tasks.migrate_hash_files",
})}
>
<Button <Button
id="migrateHashNaming" id="migrateHashNaming"
variant="danger" variant="danger"
@@ -651,9 +579,8 @@ export const SettingsTasksPanel: React.FC = () => {
> >
<FormattedMessage id="actions.rename_gen_files" /> <FormattedMessage id="actions.rename_gen_files" />
</Button> </Button>
<Form.Text className="text-muted"> </Task>
{intl.formatMessage({ id: "config.tasks.migrate_hash_files" })} </Card>
</Form.Text>
</Form.Group> </Form.Group>
</> </>
); );

View File

@@ -161,3 +161,29 @@
} }
} }
} }
.card.task-group {
padding-bottom: 0.5rem;
padding-top: 0.5rem;
.task {
padding-bottom: 0.5rem;
padding-top: 0.5rem;
&:not(:last-child) {
border-bottom: 1px solid $dark-gray2;
}
}
}
.ellipsis-button {
.btn:first-child {
border-right: 1px solid $card-bg;
}
.btn:last-child {
border-left: 1px solid $card-bg;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
}

View File

@@ -33,7 +33,7 @@ export const OperationButton: React.FC<IOperationButton> = (props) => {
externalLoading !== undefined ? externalLoading : internalLoading; externalLoading !== undefined ? externalLoading : internalLoading;
async function handleClick() { async function handleClick() {
if (operation) { if (operation && !loading) {
setLoading(true); setLoading(true);
await operation(); await operation();

View File

@@ -21,6 +21,7 @@
@import "src/components/Tagger/styles.scss"; @import "src/components/Tagger/styles.scss";
@import "src/hooks/Lightbox/lightbox.scss"; @import "src/hooks/Lightbox/lightbox.scss";
@import "src/components/Dialogs/IdentifyDialog/styles.scss"; @import "src/components/Dialogs/IdentifyDialog/styles.scss";
@import "src/components/Dialogs/styles.scss";
/* stylelint-disable */ /* stylelint-disable */
#root { #root {
@@ -381,7 +382,7 @@ div.dropdown-menu {
} }
.card { .card {
background-color: #30404d; background-color: $card-bg;
border-radius: 3px; border-radius: 3px;
box-shadow: 0 0 0 1px rgba(16, 22, 26, 0.4), 0 0 0 rgba(16, 22, 26, 0), box-shadow: 0 0 0 1px rgba(16, 22, 26, 0.4), 0 0 0 rgba(16, 22, 26, 0),
0 0 0 rgba(16, 22, 26, 0); 0 0 0 rgba(16, 22, 26, 0);
@@ -671,7 +672,7 @@ div.dropdown-menu {
} }
code { code {
background-color: darken($color: #30404d, $amount: 3); background-color: darken($color: $card-bg, $amount: 3);
color: $text-color; color: $text-color;
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
} }
@@ -689,7 +690,7 @@ div.dropdown-menu {
padding: 0; padding: 0;
} }
background-color: darken($color: #30404d, $amount: 3); background-color: darken($color: $card-bg, $amount: 3);
border-radius: 3px; border-radius: 3px;
padding: 16px; padding: 16px;
} }
@@ -711,7 +712,7 @@ div.dropdown-menu {
} }
tr:nth-child(2n) { tr:nth-child(2n) {
background-color: darken($color: #30404d, $amount: 2); background-color: darken($color: $card-bg, $amount: 2);
} }
td, td,

View File

@@ -79,8 +79,6 @@
"select_all": "Alle auswählen", "select_all": "Alle auswählen",
"select_folders": "Ordner auswählen", "select_folders": "Ordner auswählen",
"select_none": "Nichts auswählen", "select_none": "Nichts auswählen",
"selective_auto_tag": "Selektives Auto Tag",
"selective_scan": "Selektives Scannen",
"set_as_default": "Als Voreinstellung festlegen", "set_as_default": "Als Voreinstellung festlegen",
"set_back_image": "Rückseite…", "set_back_image": "Rückseite…",
"set_front_image": "Vorderseite…", "set_front_image": "Vorderseite…",

View File

@@ -78,8 +78,6 @@
"select_all": "Select All", "select_all": "Select All",
"select_folders": "Select folders", "select_folders": "Select folders",
"select_none": "Select None", "select_none": "Select None",
"selective_auto_tag": "Selective Auto Tag",
"selective_scan": "Selective Scan",
"set_as_default": "Set as default", "set_as_default": "Set as default",
"set_back_image": "Back image…", "set_back_image": "Back image…",
"set_front_image": "Front image…", "set_front_image": "Front image…",
@@ -291,14 +289,23 @@
}, },
"tasks": { "tasks": {
"added_job_to_queue": "Added {operation_name} to job queue", "added_job_to_queue": "Added {operation_name} to job queue",
"auto_tag": {
"auto_tagging_paths": "Auto Tagging the following paths",
"auto_tagging_all_paths": "Auto Tagging all paths"
},
"auto_tag_based_on_filenames": "Auto-tag content based on filenames.", "auto_tag_based_on_filenames": "Auto-tag content based on filenames.",
"auto_tagging": "Auto Tagging", "auto_tagging": "Auto Tagging",
"backing_up_database": "Backing up database", "backing_up_database": "Backing up database",
"backup_and_download": "Performs a backup of the database and downloads the resulting file.", "backup_and_download": "Performs a backup of the database and downloads the resulting file.",
"backup_database": "Performs a backup of the database to the same directory as the database, with the filename format {filename_format}", "backup_database": "Performs a backup of the database to the same directory as the database, with the filename format {filename_format}",
"cleanup_desc": "Check for missing files and remove them from the database. This is a destructive action.", "cleanup_desc": "Check for missing files and remove them from the database. This is a destructive action.",
"defaults_set": "Defaults have been set and will be used when clicking the {action} button on the Tasks page.",
"dont_include_file_extension_as_part_of_the_title": "Don't include file extension as part of the title", "dont_include_file_extension_as_part_of_the_title": "Don't include file extension as part of the title",
"export_to_json": "Exports the database content into JSON format in the metadata directory.", "export_to_json": "Exports the database content into JSON format in the metadata directory.",
"generate": {
"generating_scenes": "Generating for {num} {scene}",
"generating_from_paths": "Generating for scenes from the following paths"
},
"generate_desc": "Generate supporting image, sprite, video, vtt and other files.", "generate_desc": "Generate supporting image, sprite, video, vtt and other files.",
"generate_phashes_during_scan": "Generate phashes during scan (for deduplication and scene identification)", "generate_phashes_during_scan": "Generate phashes during scan (for deduplication and scene identification)",
"generate_previews_during_scan": "Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)", "generate_previews_during_scan": "Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)",
@@ -334,6 +341,10 @@
"migrations": "Migrations", "migrations": "Migrations",
"only_dry_run": "Only perform a dry run. Don't remove anything", "only_dry_run": "Only perform a dry run. Don't remove anything",
"plugin_tasks": "Plugin Tasks", "plugin_tasks": "Plugin Tasks",
"scan": {
"scanning_paths": "Scanning the following paths",
"scanning_all_paths": "Scanning all paths"
},
"scan_for_content_desc": "Scan for new content and add it to the database.", "scan_for_content_desc": "Scan for new content and add it to the database.",
"set_name_date_details_from_metadata_if_present": "Set name, date, details from metadata (if present)" "set_name_date_details_from_metadata_if_present": "Set name, date, details from metadata (if present)"
}, },

View File

@@ -79,8 +79,6 @@
"select_all": "Seleccionar todo", "select_all": "Seleccionar todo",
"select_folders": "Seleccionar carpetas", "select_folders": "Seleccionar carpetas",
"select_none": "Deseleccionar todo", "select_none": "Deseleccionar todo",
"selective_auto_tag": "Etiquetado automático selectivo",
"selective_scan": "Búsqueda selectiva",
"set_as_default": "Establecer por defecto", "set_as_default": "Establecer por defecto",
"set_back_image": "Contraportada…", "set_back_image": "Contraportada…",
"set_front_image": "Portada…", "set_front_image": "Portada…",

View File

@@ -79,8 +79,6 @@
"select_all": "Seleziona Tutto", "select_all": "Seleziona Tutto",
"select_folders": "Seleziona cartelle", "select_folders": "Seleziona cartelle",
"select_none": "Deseleziona Tutto", "select_none": "Deseleziona Tutto",
"selective_auto_tag": "Tag Automatico Selettivo",
"selective_scan": "Scansione Selettiva",
"set_as_default": "Imposta come Predefinito", "set_as_default": "Imposta come Predefinito",
"set_back_image": "Immagine Retro…", "set_back_image": "Immagine Retro…",
"set_front_image": "Immagine Frontale…", "set_front_image": "Immagine Frontale…",

View File

@@ -64,8 +64,6 @@
"search": "Buscar", "search": "Buscar",
"select_all": "Selecionar todos", "select_all": "Selecionar todos",
"select_none": "Selecionar nenhum", "select_none": "Selecionar nenhum",
"selective_auto_tag": "Auto Tag seletivo",
"selective_scan": "Escaneamento seletivo",
"set_as_default": "Aplicar como padrão", "set_as_default": "Aplicar como padrão",
"set_back_image": "Imagem de fundo…", "set_back_image": "Imagem de fundo…",
"set_front_image": "Imagem frontal…", "set_front_image": "Imagem frontal…",

View File

@@ -68,8 +68,6 @@
"select_all": "Välj alla", "select_all": "Välj alla",
"select_folders": "Välj mappar", "select_folders": "Välj mappar",
"select_none": "Välj inga", "select_none": "Välj inga",
"selective_auto_tag": "Selektiv Auto Tag",
"selective_scan": "Selektiv skanning",
"set_as_default": "Välj som standard", "set_as_default": "Välj som standard",
"set_back_image": "Bakbild…", "set_back_image": "Bakbild…",
"set_front_image": "Frambild…", "set_front_image": "Frambild…",

View File

@@ -79,8 +79,6 @@
"select_all": "选择所有", "select_all": "选择所有",
"select_folders": "选择目录", "select_folders": "选择目录",
"select_none": "清除选择", "select_none": "清除选择",
"selective_auto_tag": "选择性自动生成标签",
"selective_scan": "选择性扫描",
"set_as_default": "设置为默认", "set_as_default": "设置为默认",
"set_back_image": "设置背面图…", "set_back_image": "设置背面图…",
"set_front_image": "设置正面图…", "set_front_image": "设置正面图…",

View File

@@ -79,8 +79,6 @@
"select_all": "全選", "select_all": "全選",
"select_folders": "選擇資料夾", "select_folders": "選擇資料夾",
"select_none": "清除選擇", "select_none": "清除選擇",
"selective_auto_tag": "選擇性套用標籤",
"selective_scan": "選擇性掃描",
"set_as_default": "設為預設", "set_as_default": "設為預設",
"set_back_image": "設定背面圖…", "set_back_image": "設定背面圖…",
"set_front_image": "設定正面圖…", "set_front_image": "設定正面圖…",

View File

@@ -25,6 +25,7 @@ $navbar-dark-color: rgb(245, 248, 250);
$popover-bg: $secondary; $popover-bg: $secondary;
$dark-text: #182026; $dark-text: #182026;
$textfield-bg: rgba(16, 22, 26, 0.3); $textfield-bg: rgba(16, 22, 26, 0.3);
$card-bg: #30404d;
@import "node_modules/bootstrap/scss/bootstrap"; @import "node_modules/bootstrap/scss/bootstrap";
@@ -186,7 +187,7 @@ hr {
&-header, &-header,
&-body, &-body,
&-footer { &-footer {
background-color: #30404d; background-color: $card-bg;
color: $text-color; color: $text-color;
} }