diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx
index 4e566a626..f247e062b 100644
--- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx
+++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx
@@ -9,6 +9,7 @@ import { useToast } from "src/hooks/Toast";
import TextUtils from "src/utils/text";
import { TextField, URLField, URLsField } from "src/utils/field";
import { FileSize } from "src/components/Shared/FileSize";
+import NavUtils from "src/utils/navigation";
interface IFileInfoPanelProps {
file: GQL.ImageFileDataFragment | GQL.VideoFileDataFragment;
@@ -23,6 +24,7 @@ const FileInfoPanel: React.FC
= (
props: IFileInfoPanelProps
) => {
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
+ const phash = props.file.fingerprints.find((f) => f.type === "phash");
return (
@@ -36,6 +38,15 @@ const FileInfoPanel: React.FC
= (
>
)}
+
= PatchComponent(
const filterMode = GQL.FilterMode.Images;
- const otherOperations = [
+ const { modal, showModal, closeModal } = useModal();
+
+ const otherOperations: IItemListOperation[] = [
...extraOperations,
{
text: intl.formatMessage({ id: "actions.view_random" }),
onClick: viewRandom,
},
+ {
+ text: `${intl.formatMessage({ id: "actions.generate" })}…`,
+ onClick: (result, filter, selectedIds) => {
+ showModal(
+ closeModal()}
+ />
+ );
+ return Promise.resolve();
+ },
+ isDisplayed: showWhenSelected,
+ },
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
@@ -497,6 +515,7 @@ export const ImageList: React.FC = PatchComponent(
view={view}
selectable
>
+ {modal}
= ({
);
}
+ if (criterion instanceof DuplicatedCriterion) {
+ return (
+
+ );
+ }
+
if (criterion instanceof CustomFieldsCriterion) {
return (
diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx
index 162b30ff3..a6a983dc4 100644
--- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx
+++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx
@@ -80,6 +80,8 @@ export interface IFilteredListToolbar {
operations?: IListFilterOperation[];
operationComponent?: React.ReactNode;
zoomable?: boolean;
+ filterable?: boolean;
+ sortable?: boolean;
}
export const FilteredListToolbar: React.FC = ({
@@ -93,6 +95,8 @@ export const FilteredListToolbar: React.FC = ({
operations,
operationComponent,
zoomable = false,
+ filterable = true,
+ sortable = true,
}) => {
const filterOptions = filter.options;
const { setDisplayMode, setZoom } = useFilterOperations({
@@ -128,32 +132,40 @@ export const FilteredListToolbar: React.FC = ({
/>
) : (
<>
-
+ {filterable && (
+
+ )}
-
-
- showEditFilter()}
- count={filter.count()}
- />
-
+ {filterable && (
+
+
+ showEditFilter()}
+ count={filter.count()}
+ />
+
+ )}
- setFilter(filter.setSortBy(e ?? undefined))}
- onChangeSortDirection={() =>
- setFilter(filter.toggleSortDirection())
- }
- onReshuffleRandomSort={() =>
- setFilter(filter.reshuffleRandomSort())
- }
- />
+ {sortable && (
+
+ setFilter(filter.setSortBy(e ?? undefined))
+ }
+ onChangeSortDirection={() =>
+ setFilter(filter.toggleSortDirection())
+ }
+ onReshuffleRandomSort={() =>
+ setFilter(filter.reshuffleRandomSort())
+ }
+ />
+ )}
void;
+}
+
+export const DuplicatedFilter: React.FC = ({
+ criterion,
+ setCriterion,
+}) => {
+ const intl = useIntl();
+
+ function onFieldChange(
+ fieldId: DuplicationFieldId,
+ value: boolean | undefined
+ ) {
+ const c = criterion.clone();
+ if (value === undefined) {
+ delete c.value[fieldId];
+ } else {
+ c.value[fieldId] = value;
+ }
+ setCriterion(c);
+ }
+
+ return (
+
+ {DUPLICATION_FIELD_IDS.map((fieldId) => (
+ onFieldChange(fieldId, v)}
+ />
+ ))}
+
+ );
+};
+
+interface ISidebarDuplicateFilterProps {
+ title?: React.ReactNode;
+ filter: ListFilterModel;
+ setFilter: (f: ListFilterModel) => void;
+ sectionID?: string;
+}
+
+export const SidebarDuplicateFilter: React.FC = ({
+ title,
+ filter,
+ setFilter,
+ sectionID,
+}) => {
+ const intl = useIntl();
+ const [expandedType, setExpandedType] = useState(null);
+
+ const trueLabel = intl.formatMessage({ id: "true" });
+ const falseLabel = intl.formatMessage({ id: "false" });
+
+ // Get label for a duplicate type
+ const getLabel = useCallback(
+ (typeId: DuplicationFieldId) =>
+ intl.formatMessage({ id: DUPLICATION_FIELD_MESSAGE_IDS[typeId] }),
+ [intl]
+ );
+
+ // Get the single duplicated criterion from the filter
+ const getCriterion = useCallback((): DuplicatedCriterion | null => {
+ const criteria = filter.criteriaFor(
+ DuplicatedCriterionOption.type
+ ) as DuplicatedCriterion[];
+ return criteria.length > 0 ? criteria[0] : null;
+ }, [filter]);
+
+ // Get value for a specific type from the criterion
+ const getTypeValue = useCallback(
+ (typeId: DuplicationFieldId): boolean | undefined => {
+ const criterion = getCriterion();
+ if (!criterion) return undefined;
+ return criterion.value[typeId];
+ },
+ [getCriterion]
+ );
+
+ // Build selected items list
+ const selected: Option[] = useMemo(() => {
+ const result: Option[] = [];
+ const criterion = getCriterion();
+ if (!criterion) return result;
+
+ for (const typeId of DUPLICATION_FIELD_IDS) {
+ const value = criterion.value[typeId];
+ if (value !== undefined) {
+ const valueLabel = value ? trueLabel : falseLabel;
+ result.push({
+ id: typeId,
+ label: `${getLabel(typeId)}: ${valueLabel}`,
+ });
+ }
+ }
+
+ return result;
+ }, [getCriterion, trueLabel, falseLabel, getLabel]);
+
+ // Available options - show options that aren't already selected
+ const options = useMemo(() => {
+ const result: { id: DuplicationFieldId; label: string }[] = [];
+
+ for (const typeId of DUPLICATION_FIELD_IDS) {
+ if (getTypeValue(typeId) === undefined) {
+ result.push({ id: typeId, label: getLabel(typeId) });
+ }
+ }
+
+ return result;
+ }, [getTypeValue, getLabel]);
+
+ function onToggleExpand(id: string) {
+ setExpandedType(expandedType === id ? null : id);
+ }
+
+ function onUnselect(item: Option) {
+ const typeId = item.id as DuplicationFieldId;
+ const criterion = getCriterion();
+
+ if (!criterion) return;
+
+ const newCriterion = criterion.clone();
+ delete newCriterion.value[typeId];
+
+ // If no fields are set, remove the criterion entirely
+ const hasAnyValue = DUPLICATION_FIELD_IDS.some(
+ (id) => newCriterion.value[id] !== undefined
+ );
+
+ if (!hasAnyValue) {
+ setFilter(filter.removeCriterion(DuplicatedCriterionOption.type));
+ } else {
+ setFilter(
+ filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion])
+ );
+ }
+ setExpandedType(null);
+ }
+
+ function onSelectValue(typeId: string, value: boolean) {
+ const criterion = getCriterion();
+ const newCriterion = criterion
+ ? criterion.clone()
+ : (DuplicatedCriterionOption.makeCriterion() as DuplicatedCriterion);
+
+ newCriterion.value[typeId as DuplicationFieldId] = value;
+ setFilter(
+ filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion])
+ );
+ setExpandedType(null);
+ }
+
+ return (
+ onUnselect(i)} />
+ }
+ >
+
+
+ );
+};
diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx
index 200c16917..a9163578f 100644
--- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx
+++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx
@@ -18,9 +18,13 @@ import { Option } from "./SidebarListFilter";
import {
CriterionModifier,
FilterMode,
+ GalleryFilterType,
+ GroupFilterType,
InputMaybe,
IntCriterionInput,
+ PerformerFilterType,
SceneFilterType,
+ StudioFilterType,
} from "src/core/generated-graphql";
import { useIntl } from "react-intl";
@@ -82,7 +86,7 @@ export const LabeledIdFilter: React.FC = ({
);
};
-type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs";
+export type ModifierValue = "any" | "none" | "any_of" | "only" | "include_subs";
export function getModifierCandidates(props: {
modifier: CriterionModifier;
@@ -515,12 +519,25 @@ export function makeQueryVariables(query: string, extraProps: {}) {
interface IFilterType {
scenes_filter?: InputMaybe;
scene_count?: InputMaybe;
+ performers_filter?: InputMaybe;
+ performer_count?: InputMaybe;
+ galleries_filter?: InputMaybe;
+ gallery_count?: InputMaybe;
+ groups_filter?: InputMaybe;
+ group_count?: InputMaybe;
+ studios_filter?: InputMaybe;
+ studio_count?: InputMaybe;
}
export function setObjectFilter(
out: IFilterType,
mode: FilterMode,
- relatedFilterOutput: SceneFilterType
+ relatedFilterOutput:
+ | SceneFilterType
+ | PerformerFilterType
+ | GalleryFilterType
+ | GroupFilterType
+ | StudioFilterType
) {
const empty = Object.keys(relatedFilterOutput).length === 0;
@@ -533,7 +550,49 @@ export function setObjectFilter(
value: 0,
};
}
- out.scenes_filter = relatedFilterOutput;
+ out.scenes_filter = relatedFilterOutput as SceneFilterType;
break;
+ case FilterMode.Performers:
+ // if empty, only get objects with performers
+ if (empty) {
+ out.performer_count = {
+ modifier: CriterionModifier.GreaterThan,
+ value: 0,
+ };
+ }
+ out.performers_filter = relatedFilterOutput as PerformerFilterType;
+ break;
+ case FilterMode.Galleries:
+ // if empty, only get objects with galleries
+ if (empty) {
+ out.gallery_count = {
+ modifier: CriterionModifier.GreaterThan,
+ value: 0,
+ };
+ }
+ out.galleries_filter = relatedFilterOutput as GalleryFilterType;
+ break;
+ case FilterMode.Groups:
+ // if empty, only get objects with groups
+ if (empty) {
+ out.group_count = {
+ modifier: CriterionModifier.GreaterThan,
+ value: 0,
+ };
+ }
+ out.groups_filter = relatedFilterOutput as GroupFilterType;
+ break;
+ case FilterMode.Studios:
+ // if empty, only get objects with studios
+ if (empty) {
+ out.studio_count = {
+ modifier: CriterionModifier.GreaterThan,
+ value: 0,
+ };
+ }
+ out.studios_filter = relatedFilterOutput as StudioFilterType;
+ break;
+ default:
+ throw new Error("Invalid filter mode");
}
}
diff --git a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx
index d9cfaf733..6753df09d 100644
--- a/ui/v2.5/src/components/List/Filters/OptionFilter.tsx
+++ b/ui/v2.5/src/components/List/Filters/OptionFilter.tsx
@@ -1,10 +1,20 @@
import cloneDeep from "lodash-es/cloneDeep";
-import React from "react";
+import React, { useMemo } from "react";
import { Form } from "react-bootstrap";
import {
CriterionValue,
ModifierCriterion,
+ ModifierCriterionOption,
} from "src/models/list-filter/criteria/criterion";
+import { ListFilterModel } from "src/models/list-filter/filter";
+import { Option, SidebarListFilter } from "./SidebarListFilter";
+import { CriterionModifier } from "src/core/generated-graphql";
+import {
+ getModifierCandidates,
+ ModifierValue,
+ modifierValueToModifier,
+} from "./LabeledIdFilter";
+import { useIntl } from "react-intl";
interface IOptionsFilter {
criterion: ModifierCriterion;
@@ -83,3 +93,142 @@ export const OptionListFilter: React.FC = ({
);
};
+
+interface ISidebarFilter {
+ title?: React.ReactNode;
+ option: ModifierCriterionOption;
+ filter: ListFilterModel;
+ setFilter: (f: ListFilterModel) => void;
+ sectionID?: string;
+}
+
+export const SidebarOptionFilter: React.FC = ({
+ title,
+ option,
+ filter,
+ setFilter,
+ sectionID,
+}) => {
+ const intl = useIntl();
+
+ const criteria = filter.criteriaFor(
+ option.type
+ ) as ModifierCriterion[];
+ const criterion = criteria.length > 0 ? criteria[0] : null;
+ const { options: criterionOptions = [] } = option;
+ const currentValues = criteria.flatMap((c) => c.value as string[]);
+
+ const hasNullModifiers =
+ option.modifierOptions.includes(CriterionModifier.IsNull) &&
+ option.modifierOptions.includes(CriterionModifier.NotNull);
+
+ const selected: Option[] = useMemo(() => {
+ if (!criterion) return [];
+
+ if (criterion.modifier === CriterionModifier.IsNull) {
+ return [
+ {
+ id: "none",
+ label: intl.formatMessage({ id: "criterion_modifier_values.none" }),
+ },
+ ];
+ } else if (criterion.modifier === CriterionModifier.NotNull) {
+ return [
+ {
+ id: "any",
+ label: intl.formatMessage({ id: "criterion_modifier_values.any" }),
+ },
+ ];
+ }
+
+ return criterionOptions
+ .filter((o) => currentValues.includes(o.toString()))
+ .map((o) => ({
+ id: o.toString(),
+ label: o.toLocaleString(),
+ }));
+ }, [criterion, currentValues, criterionOptions, intl]);
+
+ const modifierCandidates: Option[] = useMemo(() => {
+ if (!hasNullModifiers) return [];
+
+ const c = getModifierCandidates({
+ modifier: criterion?.modifier ?? option.defaultModifier,
+ defaultModifier: option.defaultModifier,
+ hasExcluded: false,
+ hasSelected: selected.length > 0,
+ singleValue: true, // so that it doesn't include any_of
+ });
+
+ return c.map((v) => {
+ const messageID = `criterion_modifier_values.${v}`;
+
+ return {
+ id: v,
+ label: `(${intl.formatMessage({
+ id: messageID,
+ })})`,
+ className: "modifier-object",
+ canExclude: false,
+ };
+ });
+ }, [criterion, option, selected, hasNullModifiers, intl]);
+
+ const options = useMemo(() => {
+ const o = criterionOptions
+ .filter((oo) => !currentValues.includes(oo.toString()))
+ .map((oo) => ({
+ id: oo.toString(),
+ label: oo.toString(),
+ }));
+
+ return [...modifierCandidates, ...o];
+ }, [criterionOptions, currentValues, modifierCandidates]);
+
+ function onSelect(item: Option) {
+ const newCriterion = criterion ? criterion.clone() : option.makeCriterion();
+
+ if (item.className === "modifier-object") {
+ newCriterion.modifier = modifierValueToModifier(item.id as ModifierValue);
+ newCriterion.value = [];
+ setFilter(filter.replaceCriteria(option.type, [newCriterion]));
+ return;
+ }
+
+ const cv = newCriterion.value as string[];
+ if (cv.includes(item.id)) {
+ return;
+ } else {
+ newCriterion.value = [...cv, item.id];
+ }
+
+ setFilter(filter.replaceCriteria(option.type, [newCriterion]));
+ }
+
+ function onUnselect(item: Option) {
+ if (item.className === "modifier-object") {
+ const newCriterion = criterion
+ ? criterion.clone()
+ : option.makeCriterion();
+ newCriterion.modifier = option.defaultModifier;
+ setFilter(filter.replaceCriteria(option.type, [newCriterion]));
+ return;
+ }
+
+ setFilter(filter.removeCriterion(option.type));
+ }
+
+ return (
+ <>
+
+ >
+ );
+};
diff --git a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx
index 3df19593f..7e0dee855 100644
--- a/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx
+++ b/ui/v2.5/src/components/List/Filters/PerformersFilter.tsx
@@ -1,5 +1,8 @@
import React, { ReactNode, useMemo } from "react";
-import { PerformersCriterion } from "src/models/list-filter/criteria/performers";
+import {
+ PerformersCriterion,
+ PerformersCriterionOption,
+} from "src/models/list-filter/criteria/performers";
import {
CriterionModifier,
FindPerformersForSelectQueryVariables,
@@ -18,6 +21,7 @@ import {
useLabeledIdFilterState,
} from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
+import { FormattedMessage } from "react-intl";
interface IPerformersFilter {
criterion: PerformersCriterion;
@@ -106,12 +110,19 @@ const PerformersFilter: React.FC = ({
export const SidebarPerformersFilter: React.FC<{
title?: ReactNode;
- option: CriterionOption;
+ option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
sectionID?: string;
-}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
+}> = ({
+ title = ,
+ option = PerformersCriterionOption,
+ filter,
+ setFilter,
+ filterHook,
+ sectionID = "performers",
+}) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@@ -120,7 +131,14 @@ export const SidebarPerformersFilter: React.FC<{
useQuery: usePerformerQueryFilter,
});
- return ;
+ return (
+
+ );
};
export default PerformersFilter;
diff --git a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx
index 9f5c8f8c9..8a07d54f9 100644
--- a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx
+++ b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx
@@ -13,7 +13,10 @@ import {
defaultRatingSystemOptions,
} from "src/utils/rating";
import { useConfigurationContext } from "src/hooks/Config";
-import { RatingCriterion } from "src/models/list-filter/criteria/rating";
+import {
+ RatingCriterion,
+ RatingCriterionOption,
+} from "src/models/list-filter/criteria/rating";
import { ListFilterModel } from "src/models/list-filter/filter";
import { Option, SidebarListFilter } from "./SidebarListFilter";
@@ -74,7 +77,7 @@ export const RatingFilter: React.FC = ({
interface ISidebarFilter {
title?: React.ReactNode;
- option: CriterionOption;
+ option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
sectionID?: string;
@@ -84,11 +87,11 @@ const any = "any";
const none = "none";
export const SidebarRatingFilter: React.FC = ({
- title,
- option,
+ title = ,
+ option = RatingCriterionOption,
filter,
setFilter,
- sectionID,
+ sectionID = "rating",
}) => {
const intl = useIntl();
@@ -193,6 +196,7 @@ export const SidebarRatingFilter: React.FC = ({
return (
<>
void;
sectionID?: string;
@@ -55,11 +57,11 @@ function snapToStep(value: number): number {
}
export const SidebarDurationFilter: React.FC = ({
- title,
- option,
+ title = ,
+ option = DurationCriterionOption,
filter,
setFilter,
- sectionID,
+ sectionID = "duration",
}) => {
const criteria = filter.criteriaFor(option.type) as DurationCriterion[];
const criterion = criteria.length > 0 ? criteria[0] : null;
diff --git a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx
index e922e688a..3e28bd927 100644
--- a/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx
+++ b/ui/v2.5/src/components/List/Filters/StudiosFilter.tsx
@@ -5,7 +5,10 @@ import {
useFindStudiosForSelectQuery,
} from "src/core/generated-graphql";
import { HierarchicalObjectsFilter } from "./SelectableFilter";
-import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
+import {
+ StudiosCriterion,
+ StudiosCriterionOption,
+} from "src/models/list-filter/criteria/studios";
import { sortByRelevance } from "src/utils/query";
import { CriterionOption } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
@@ -16,6 +19,7 @@ import {
useLabeledIdFilterState,
} from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
+import { FormattedMessage } from "react-intl";
interface IStudiosFilter {
criterion: StudiosCriterion;
@@ -94,12 +98,19 @@ const StudiosFilter: React.FC = ({
export const SidebarStudiosFilter: React.FC<{
title?: ReactNode;
- option: CriterionOption;
+ option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
sectionID?: string;
-}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
+}> = ({
+ title = ,
+ option = StudiosCriterionOption,
+ filter,
+ setFilter,
+ filterHook,
+ sectionID = "studios",
+}) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@@ -111,7 +122,14 @@ export const SidebarStudiosFilter: React.FC<{
includeSubMessageID: "subsidiary_studios",
});
- return ;
+ return (
+
+ );
};
export default StudiosFilter;
diff --git a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx
index f4c618ffa..446a90331 100644
--- a/ui/v2.5/src/components/List/Filters/TagsFilter.tsx
+++ b/ui/v2.5/src/components/List/Filters/TagsFilter.tsx
@@ -16,7 +16,11 @@ import {
useLabeledIdFilterState,
} from "./LabeledIdFilter";
import { SidebarListFilter } from "./SidebarListFilter";
-import { TagsCriterion } from "src/models/list-filter/criteria/tags";
+import {
+ TagsCriterion,
+ TagsCriterionOption,
+} from "src/models/list-filter/criteria/tags";
+import { FormattedMessage } from "react-intl";
interface ITagsFilter {
criterion: TagsCriterion;
@@ -99,12 +103,19 @@ const TagsFilter: React.FC = ({ criterion, setCriterion }) => {
export const SidebarTagsFilter: React.FC<{
title?: ReactNode;
- option: CriterionOption;
+ option?: CriterionOption;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
filterHook?: (f: ListFilterModel) => ListFilterModel;
sectionID?: string;
-}> = ({ title, option, filter, setFilter, filterHook, sectionID }) => {
+}> = ({
+ title = ,
+ option = TagsCriterionOption,
+ filter,
+ setFilter,
+ filterHook,
+ sectionID = "tags",
+}) => {
const state = useLabeledIdFilterState({
filter,
setFilter,
@@ -115,7 +126,14 @@ export const SidebarTagsFilter: React.FC<{
includeSubMessageID: "sub_tags",
});
- return ;
+ return (
+
+ );
};
export default TagsFilter;
diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx
index b377cedba..2a4232fb3 100644
--- a/ui/v2.5/src/components/List/ListOperationButtons.tsx
+++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx
@@ -6,7 +6,9 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Icon } from "../Shared/Icon";
import {
faEllipsisH,
+ faPencil,
faPencilAlt,
+ faPlay,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import cx from "classnames";
@@ -58,6 +60,7 @@ export interface IListFilterOperation {
isDisplayed?: () => boolean;
icon?: IconDefinition;
buttonVariant?: string;
+ className?: string;
}
interface IListOperationButtonsProps {
@@ -264,3 +267,148 @@ export const ListOperationButtons: React.FC = ({
>
);
};
+
+export const ListOperations: React.FC<{
+ items: number;
+ hasSelection?: boolean;
+ operations?: IListFilterOperation[];
+ onEdit?: () => void;
+ onDelete?: () => void;
+ onPlay?: () => void;
+ operationsClassName?: string;
+ operationsMenuClassName?: string;
+}> = ({
+ items,
+ hasSelection = false,
+ operations = [],
+ onEdit,
+ onDelete,
+ onPlay,
+ operationsClassName = "list-operations",
+ operationsMenuClassName,
+}) => {
+ const intl = useIntl();
+
+ const dropdownOperations = useMemo(() => {
+ return operations.filter((o) => {
+ if (o.icon) {
+ return false;
+ }
+
+ if (!o.isDisplayed) {
+ return true;
+ }
+
+ return o.isDisplayed();
+ });
+ }, [operations]);
+
+ const buttons = useMemo(() => {
+ const otherButtons = (operations ?? []).filter((o) => {
+ if (!o.icon) {
+ return false;
+ }
+
+ if (!o.isDisplayed) {
+ return true;
+ }
+
+ return o.isDisplayed();
+ });
+
+ const ret: React.ReactNode[] = [];
+
+ function addButton(b: React.ReactNode | null) {
+ if (b) {
+ ret.push(b);
+ }
+ }
+
+ const playButton =
+ !!items && onPlay ? (
+
+ ) : null;
+
+ const editButton =
+ hasSelection && onEdit ? (
+
+ ) : null;
+
+ const deleteButton =
+ hasSelection && onDelete ? (
+
+ ) : null;
+
+ addButton(playButton);
+ addButton(editButton);
+ addButton(deleteButton);
+
+ otherButtons.forEach((button) => {
+ addButton(
+
+ );
+ });
+
+ if (ret.length === 0) {
+ return null;
+ }
+
+ return ret;
+ }, [operations, hasSelection, onDelete, onEdit, onPlay, items, intl]);
+
+ if (dropdownOperations.length === 0 && !buttons) {
+ return null;
+ }
+
+ return (
+
+
+ {buttons}
+
+ {dropdownOperations.length > 0 && (
+
+ {dropdownOperations.map((o) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss
index 5f1b4da2a..e7a4caf02 100644
--- a/ui/v2.5/src/components/List/styles.scss
+++ b/ui/v2.5/src/components/List/styles.scss
@@ -726,6 +726,24 @@ input[type="range"].zoom-slider {
min-height: 2em;
}
+.duplicate-sub-options {
+ margin-left: 2rem;
+ padding-left: 0.5rem;
+
+ .duplicate-sub-option {
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ height: 2em;
+ opacity: 0.8;
+ padding-left: 0.5rem;
+
+ &:hover {
+ background-color: rgba(138, 155, 168, 0.15);
+ }
+ }
+}
+
.tilted {
transform: rotate(45deg);
}
@@ -1120,7 +1138,8 @@ input[type="range"].zoom-slider {
justify-content: flex-end;
}
-.scene-list-toolbar .selected-items-info {
+.scene-list-toolbar .selected-items-info,
+.gallery-list-toolbar .selected-items-info {
justify-content: flex-start;
}
diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts
index 707346848..d870c631f 100644
--- a/ui/v2.5/src/components/List/util.ts
+++ b/ui/v2.5/src/components/List/util.ts
@@ -139,6 +139,7 @@ function useEmptyFilter(props: {
export interface IFilterStateHook {
filterMode: GQL.FilterMode;
+ defaultFilter?: ListFilterModel;
defaultSort?: string;
view?: View;
useURL?: boolean;
@@ -149,7 +150,14 @@ export function useFilterState(
config?: GQL.ConfigDataFragment;
}
) {
- const { filterMode, defaultSort, config, view, useURL } = props;
+ const {
+ filterMode,
+ defaultSort,
+ config,
+ view,
+ useURL,
+ defaultFilter: propDefaultFilter,
+ } = props;
const [filter, setFilterState] = useState(
() =>
@@ -158,10 +166,13 @@ export function useFilterState(
const emptyFilter = useEmptyFilter({ filterMode, defaultSort, config });
- const { defaultFilter } = useDefaultFilter(emptyFilter, view);
+ const { defaultFilter: defaultFilterFromConfig } = useDefaultFilter(
+ emptyFilter,
+ view
+ );
const { setFilter } = useFilterURL(filter, setFilterState, {
- defaultFilter,
+ defaultFilter: propDefaultFilter ?? defaultFilterFromConfig,
active: useURL,
});
diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts
index 5b9f9798f..4ea4e46d8 100644
--- a/ui/v2.5/src/components/List/views.ts
+++ b/ui/v2.5/src/components/List/views.ts
@@ -13,6 +13,7 @@ export enum View {
TagScenes = "tag_scenes",
TagImages = "tag_images",
TagPerformers = "tag_performers",
+ TagGroups = "tag_groups",
PerformerScenes = "performer_scenes",
PerformerGalleries = "performer_galleries",
diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx
index caee46f0c..c70994476 100644
--- a/ui/v2.5/src/components/MainNavbar.tsx
+++ b/ui/v2.5/src/components/MainNavbar.tsx
@@ -103,6 +103,7 @@ const allMenuItems: IMenuItem[] = [
href: "/scenes",
icon: faPlayCircle,
hotkey: "g s",
+ userCreatable: true,
},
{
name: "images",
diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx
index 677ac3aa1..d60118d4b 100644
--- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx
+++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx
@@ -42,7 +42,8 @@ const performerFields = [
"gender",
"birthdate",
"death_date",
- "career_length",
+ "career_start",
+ "career_end",
"country",
"ethnicity",
"eye_color",
@@ -363,8 +364,15 @@ export const EditPerformersDialog: React.FC = (
{renderTextField("piercings", updateInput.piercings, (v) =>
setUpdateField({ piercings: v })
)}
- {renderTextField("career_length", updateInput.career_length, (v) =>
- setUpdateField({ career_length: v })
+ {renderTextField(
+ "career_start",
+ updateInput.career_start?.toString(),
+ (v) => setUpdateField({ career_start: v ? parseInt(v) : undefined })
+ )}
+ {renderTextField(
+ "career_end",
+ updateInput.career_end?.toString(),
+ (v) => setUpdateField({ career_end: v ? parseInt(v) : undefined })
)}
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx
index 95e03ff8b..473bbbd47 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx
@@ -12,6 +12,7 @@ import {
FormatHeight,
FormatPenisLength,
FormatWeight,
+ formatYearRange,
} from "../PerformerList";
import { PatchComponent } from "src/patch";
import { CustomFields } from "src/components/Shared/CustomFields";
@@ -174,7 +175,10 @@ export const PerformerDetailsPanel: React.FC =
/>
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
index 7bb8d399a..98871bf9a 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
@@ -44,7 +44,7 @@ import {
yupInputNumber,
yupInputEnum,
yupDateString,
- yupUniqueAliases,
+ yupRequiredStringArray,
yupUniqueStringList,
} from "src/utils/yup";
import { useTagsEdit } from "src/hooks/tagsEdit";
@@ -110,7 +110,7 @@ export const PerformerEditPanel: React.FC = ({
const schema = yup.object({
name: yup.string().required(),
disambiguation: yup.string().ensure(),
- alias_list: yupUniqueAliases(intl, "name"),
+ alias_list: yupRequiredStringArray(intl).defined(),
gender: yupInputEnum(GQL.GenderEnum).nullable().defined(),
birthdate: yupDateString(intl),
death_date: yupDateString(intl),
@@ -126,7 +126,8 @@ export const PerformerEditPanel: React.FC = ({
circumcised: yupInputEnum(GQL.CircumisedEnum).nullable().defined(),
tattoos: yup.string().ensure(),
piercings: yup.string().ensure(),
- career_length: yup.string().ensure(),
+ career_start: yupInputNumber().positive().nullable().defined(),
+ career_end: yupInputNumber().positive().nullable().defined(),
urls: yupUniqueStringList(intl),
details: yup.string().ensure(),
tag_ids: yup.array(yup.string().required()).defined(),
@@ -155,7 +156,8 @@ export const PerformerEditPanel: React.FC = ({
circumcised: performer.circumcised ?? null,
tattoos: performer.tattoos ?? "",
piercings: performer.piercings ?? "",
- career_length: performer.career_length ?? "",
+ career_start: performer.career_start ?? null,
+ career_end: performer.career_end ?? null,
urls: performer.urls ?? [],
details: performer.details ?? "",
tag_ids: (performer.tags ?? []).map((t) => t.id),
@@ -256,8 +258,11 @@ export const PerformerEditPanel: React.FC = ({
if (state.fake_tits) {
formik.setFieldValue("fake_tits", state.fake_tits);
}
- if (state.career_length) {
- formik.setFieldValue("career_length", state.career_length);
+ if (state.career_start) {
+ formik.setFieldValue("career_start", state.career_start);
+ }
+ if (state.career_end) {
+ formik.setFieldValue("career_end", state.career_end);
}
if (state.tattoos) {
formik.setFieldValue("tattoos", state.tattoos);
@@ -747,7 +752,8 @@ export const PerformerEditPanel: React.FC = ({
{renderInputField("tattoos", "textarea")}
{renderInputField("piercings", "textarea")}
- {renderInputField("career_length")}
+ {renderInputField("career_start", "number")}
+ {renderInputField("career_end", "number")}
{renderURLListField("urls", onScrapePerformerURL, urlScrapable)}
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx
index 5a9d0b81d..44b0401e9 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx
@@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
-import { GalleryList } from "src/components/Galleries/GalleryList";
+import { FilteredGalleryList } from "src/components/Galleries/GalleryList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
@@ -14,7 +14,7 @@ export const PerformerGalleriesPanel: React.FC =
PatchComponent("PerformerGalleriesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
- =
PatchComponent("PerformerGroupsPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
- = (
const [fakeTits, setFakeTits] = useState>(
new ScrapeResult(props.performer.fake_tits, props.scraped.fake_tits)
);
- const [careerLength, setCareerLength] = useState>(
- new ScrapeResult(
- props.performer.career_length,
- props.scraped.career_length
+ const [careerStart, setCareerStart] = useState>(
+ new ScrapeResult(
+ props.performer.career_start,
+ props.scraped.career_start
+ )
+ );
+ const [careerEnd, setCareerEnd] = useState>(
+ new ScrapeResult(
+ props.performer.career_end,
+ props.scraped.career_end
)
);
const [tattoos, setTattoos] = useState>(
@@ -347,7 +354,8 @@ export const PerformerScrapeDialog: React.FC = (
fakeTits,
penisLength,
circumcised,
- careerLength,
+ careerStart,
+ careerEnd,
tattoos,
piercings,
urls,
@@ -379,7 +387,8 @@ export const PerformerScrapeDialog: React.FC = (
height: height.getNewValue(),
measurements: measurements.getNewValue(),
fake_tits: fakeTits.getNewValue(),
- career_length: careerLength.getNewValue(),
+ career_start: careerStart.getNewValue(),
+ career_end: careerEnd.getNewValue(),
tattoos: tattoos.getNewValue(),
piercings: piercings.getNewValue(),
urls: urls.getNewValue(),
@@ -493,11 +502,17 @@ export const PerformerScrapeDialog: React.FC = (
result={fakeTits}
onChange={(value) => setFakeTits(value)}
/>
- setCareerLength(value)}
+ setCareerStart(value)}
+ />
+ setCareerEnd(value)}
/>
=
const filterHook = usePerformerFilterHook(performer);
return (
- {
const intl = useIntl();
@@ -112,6 +137,14 @@ export const FormatWeight = (weight?: number | null) => {
);
};
+export function formatYearRange(
+ start?: number | null,
+ end?: number | null
+): string | undefined {
+ if (!start && !end) return undefined;
+ return `${start ?? ""} - ${end ?? ""}`;
+}
+
export const FormatCircumcised = (circumcised?: GQL.CircumisedEnum | null) => {
const intl = useIntl();
if (!circumcised) {
@@ -165,193 +198,292 @@ interface IPerformerList {
extraOperations?: IItemListOperation[];
}
-export const PerformerList: React.FC = PatchComponent(
+const PerformerList: React.FC<{
+ performers: GQL.PerformerDataFragment[];
+ filter: ListFilterModel;
+ selectedIds: Set;
+ onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
+ extraCriteria?: IPerformerCardExtraCriteria;
+}> = PatchComponent(
"PerformerList",
- ({ filterHook, view, alterQuery, extraCriteria, extraOperations = [] }) => {
+ ({ performers, filter, selectedIds, onSelectChange, extraCriteria }) => {
+ if (performers.length === 0) {
+ return null;
+ }
+
+ if (filter.displayMode === DisplayMode.Grid) {
+ return (
+
+ );
+ }
+ if (filter.displayMode === DisplayMode.List) {
+ return (
+
+ );
+ }
+ if (filter.displayMode === DisplayMode.Tagger) {
+ return ;
+ }
+
+ return null;
+ }
+);
+
+const PerformerFilterSidebarSections = PatchContainerComponent(
+ "FilteredPerformerList.SidebarSections"
+);
+
+const SidebarContent: React.FC<{
+ filter: ListFilterModel;
+ setFilter: (filter: ListFilterModel) => void;
+ filterHook?: (filter: ListFilterModel) => ListFilterModel;
+ view?: View;
+ sidebarOpen: boolean;
+ onClose?: () => void;
+ showEditFilter: (editingCriterion?: string) => void;
+ count?: number;
+ focus?: ReturnType;
+}> = ({
+ filter,
+ setFilter,
+ filterHook,
+ view,
+ showEditFilter,
+ sidebarOpen,
+ onClose,
+ count,
+ focus,
+}) => {
+ const showResultsId =
+ count !== undefined ? "actions.show_count_results" : "actions.show_results";
+
+ const AgeCriterionOption = PerformerListFilterOptions.criterionOptions.find(
+ (c) => c.type === "age"
+ );
+
+ return (
+ <>
+
+
+
+
+
+ }
+ data-type={FavoritePerformerCriterionOption.type}
+ option={FavoritePerformerCriterionOption}
+ filter={filter}
+ setFilter={setFilter}
+ sectionID="favourite"
+ />
+ }
+ option={GenderCriterionOption}
+ filter={filter}
+ setFilter={setFilter}
+ sectionID="gender"
+ />
+ }
+ option={AgeCriterionOption!}
+ filter={filter}
+ setFilter={setFilter}
+ sectionID="age"
+ />
+
+
+
+
+
+ >
+ );
+};
+
+function useViewRandom(filter: ListFilterModel, count: number) {
+ const history = useHistory();
+
+ const viewRandom = useCallback(async () => {
+ // query for a random performer
+ if (count === 0) {
+ return;
+ }
+
+ const index = Math.floor(Math.random() * count);
+ const filterCopy = cloneDeep(filter);
+ filterCopy.itemsPerPage = 1;
+ filterCopy.currentPage = index + 1;
+ const singleResult = await queryFindPerformers(filterCopy);
+ if (singleResult.data.findPerformers.performers.length === 1) {
+ const { id } = singleResult.data.findPerformers.performers[0];
+ // navigate to the image player page
+ history.push(`/performers/${id}`);
+ }
+ }, [history, filter, count]);
+
+ return viewRandom;
+}
+
+function useAddKeybinds(filter: ListFilterModel, count: number) {
+ const viewRandom = useViewRandom(filter, count);
+
+ useEffect(() => {
+ Mousetrap.bind("p r", () => {
+ viewRandom();
+ });
+
+ return () => {
+ Mousetrap.unbind("p r");
+ };
+ }, [viewRandom]);
+}
+
+export const FilteredPerformerList = PatchComponent(
+ "FilteredPerformerList",
+ (props: IPerformerList) => {
const intl = useIntl();
const history = useHistory();
- const [mergePerformers, setMergePerformers] = useState<
- GQL.SelectPerformerDataFragment[] | undefined
- >(undefined);
- const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
- const [isExportAll, setIsExportAll] = useState(false);
- const filterMode = GQL.FilterMode.Performers;
+ const searchFocus = useFocus();
- const otherOperations = [
- ...extraOperations,
- {
- text: intl.formatMessage({ id: "actions.open_random" }),
- onClick: openRandom,
- },
- {
- text: `${intl.formatMessage({ id: "actions.merge" })}…`,
- onClick: merge,
- isDisplayed: showWhenSelected,
- },
- {
- text: intl.formatMessage({ id: "actions.export" }),
- onClick: onExport,
- isDisplayed: showWhenSelected,
- },
- {
- text: intl.formatMessage({ id: "actions.export_all" }),
- onClick: onExportAll,
- },
- ];
+ const {
+ filterHook,
+ view,
+ alterQuery,
+ extraCriteria,
+ extraOperations = [],
+ } = props;
- function addKeybinds(
- result: GQL.FindPerformersQueryResult,
- filter: ListFilterModel
- ) {
- Mousetrap.bind("p r", () => {
- openRandom(result, filter);
+ // States
+ const {
+ showSidebar,
+ setShowSidebar,
+ sectionOpen,
+ setSectionOpen,
+ loading: sidebarStateLoading,
+ } = useSidebarState(view);
+
+ const { filterState, queryResult, modalState, listSelect, showEditFilter } =
+ useFilteredItemList({
+ filterStateProps: {
+ filterMode: GQL.FilterMode.Performers,
+ view,
+ useURL: alterQuery,
+ },
+ queryResultProps: {
+ useResult: useFindPerformers,
+ getCount: (r) => r.data?.findPerformers.count ?? 0,
+ getItems: (r) => r.data?.findPerformers.performers ?? [],
+ filterHook,
+ },
+ });
+
+ const { filter, setFilter } = filterState;
+
+ const { effectiveFilter, result, cachedResult, items, totalCount } =
+ queryResult;
+
+ const {
+ selectedIds,
+ selectedItems,
+ onSelectChange,
+ onSelectAll,
+ onSelectNone,
+ onInvertSelection,
+ hasSelection,
+ } = listSelect;
+
+ const { modal, showModal, closeModal } = modalState;
+
+ // Utility hooks
+ const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
+ filter,
+ setFilter,
+ });
+
+ useAddKeybinds(filter, totalCount);
+ useFilteredSidebarKeybinds({
+ showSidebar,
+ setShowSidebar,
+ });
+
+ useEffect(() => {
+ Mousetrap.bind("e", () => {
+ if (hasSelection) {
+ onEdit?.();
+ }
+ });
+
+ Mousetrap.bind("d d", () => {
+ if (hasSelection) {
+ onDelete?.();
+ }
});
return () => {
- Mousetrap.unbind("p r");
+ Mousetrap.unbind("e");
+ Mousetrap.unbind("d d");
};
- }
+ });
- async function openRandom(
- result: GQL.FindPerformersQueryResult,
- filter: ListFilterModel
- ) {
- if (result.data?.findPerformers) {
- const { count } = result.data.findPerformers;
- const index = Math.floor(Math.random() * count);
- const filterCopy = cloneDeep(filter);
- filterCopy.itemsPerPage = 1;
- filterCopy.currentPage = index + 1;
- const singleResult = await queryFindPerformers(filterCopy);
- if (singleResult.data.findPerformers.performers.length === 1) {
- const { id } = singleResult.data.findPerformers.performers[0]!;
- history.push(`/performers/${id}`);
- }
- }
- }
+ const onCloseEditDelete = useCloseEditDelete({
+ closeModal,
+ onSelectNone,
+ result,
+ });
- async function merge(
- result: GQL.FindPerformersQueryResult,
- filter: ListFilterModel,
- selectedIds: Set
- ) {
- const selected =
- result.data?.findPerformers.performers.filter((p) =>
- selectedIds.has(p.id)
- ) ?? [];
- setMergePerformers(selected);
- }
+ const viewRandom = useViewRandom(filter, totalCount);
- async function onExport() {
- setIsExportAll(false);
- setIsExportDialogOpen(true);
- }
-
- async function onExportAll() {
- setIsExportAll(true);
- setIsExportDialogOpen(true);
- }
-
- function renderContent(
- result: GQL.FindPerformersQueryResult,
- filter: ListFilterModel,
- selectedIds: Set,
- onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
- ) {
- function renderMergeDialog() {
- if (mergePerformers) {
- return (
- {
- setMergePerformers(undefined);
- if (mergedId) {
- history.push(`/performers/${mergedId}`);
- }
- }}
- show
- />
- );
- }
- }
-
- function maybeRenderPerformerExportDialog() {
- if (isExportDialogOpen) {
- return (
- <>
- setIsExportDialogOpen(false)}
- />
- >
- );
- }
- }
-
- function renderPerformers() {
- if (!result.data?.findPerformers) return;
-
- if (filter.displayMode === DisplayMode.Grid) {
- return (
-
- );
- }
- if (filter.displayMode === DisplayMode.List) {
- return (
-
- );
- }
- if (filter.displayMode === DisplayMode.Tagger) {
- return (
-
- );
- }
- }
-
- return (
- <>
- {renderMergeDialog()}
- {maybeRenderPerformerExportDialog()}
- {renderPerformers()}
- >
+ function onExport(all: boolean) {
+ showModal(
+ closeModal()}
+ />
);
}
- function renderEditDialog(
- selectedPerformers: GQL.SlimPerformerDataFragment[],
- onClose: (applied: boolean) => void
- ) {
- return (
-
+ function onEdit() {
+ showModal(
+
);
}
- function renderDeleteDialog(
- selectedPerformers: GQL.SlimPerformerDataFragment[],
- onClose: (confirmed: boolean) => void
- ) {
- return (
+ function onDelete() {
+ showModal(
= PatchComponent(
);
}
- return (
-
- {
+ closeModal();
+ if (mergedId) {
+ history.push(`/performers/${mergedId}`);
+ }
+ }}
+ show
/>
-
+ );
+ }
+
+ const convertedExtraOperations: IListFilterOperation[] =
+ extraOperations.map((o) => ({
+ ...o,
+ isDisplayed: o.isDisplayed
+ ? () => o.isDisplayed!(result, filter, selectedIds)
+ : undefined,
+ onClick: () => {
+ o.onClick(result, filter, selectedIds);
+ },
+ }));
+
+ const otherOperations: IListFilterOperation[] = [
+ ...convertedExtraOperations,
+ {
+ text: intl.formatMessage({ id: "actions.select_all" }),
+ onClick: () => onSelectAll(),
+ isDisplayed: () => totalCount > 0,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.select_none" }),
+ onClick: () => onSelectNone(),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.invert_selection" }),
+ onClick: () => onInvertSelection(),
+ isDisplayed: () => totalCount > 0,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.open_random" }),
+ onClick: viewRandom,
+ },
+ {
+ text: `${intl.formatMessage({ id: "actions.merge" })}…`,
+ onClick: onMerge,
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.export" }),
+ onClick: () => onExport(false),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.export_all" }),
+ onClick: () => onExport(true),
+ },
+ ];
+
+ // render
+ if (sidebarStateLoading) return null;
+
+ const operations = (
+
+ );
+
+ return (
+
+ {modal}
+
+
+
+ setShowSidebar(false)}>
+ setShowSidebar(false)}
+ count={cachedResult.loading ? undefined : totalCount}
+ focus={searchFocus}
+ />
+
+ setShowSidebar(!showSidebar)}
+ >
+
+
+ showEditFilter(c.criterionOption.type)}
+ onRemoveCriterion={removeCriterion}
+ onRemoveAll={clearAllCriteria}
+ />
+
+
+
setFilter(filter.changePage(page))}
+ />
+
+
+
+
+
+
+
+ {totalCount > filter.itemsPerPage && (
+
+ )}
+
+
+
+
);
}
);
diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx
index 58538e7e2..3b500cee6 100644
--- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx
@@ -17,6 +17,7 @@ import {
FormatHeight,
FormatPenisLength,
FormatWeight,
+ formatYearRange,
} from "./PerformerList";
import TextUtils from "src/utils/text";
import { getCountryByISO } from "src/utils/country";
@@ -188,7 +189,7 @@ export const PerformerListTable: React.FC = (
);
const CareerLengthCell = (performer: GQL.PerformerDataFragment) => (
- {performer.career_length}
+ <>{formatYearRange(performer.career_start, performer.career_end) ?? ""}>
);
const SceneCountCell = (performer: GQL.PerformerDataFragment) => (
diff --git a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
index 834d2ac76..efa51f1db 100644
--- a/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerMergeDialog.tsx
@@ -19,6 +19,7 @@ import { useToast } from "src/hooks/Toast";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
import {
+ ScrapedCustomFieldRows,
ScrapedImageRow,
ScrapedInputGroupRow,
ScrapedStringListRow,
@@ -27,9 +28,9 @@ import {
import { ModalComponent } from "../Shared/Modal";
import { sortStoredIdObjects } from "src/utils/data";
import {
+ CustomFieldScrapeResults,
ObjectListScrapeResult,
ScrapeResult,
- ZeroableScrapeResult,
hasScrapedValues,
} from "../Shared/ScrapeDialog/scrapeResult";
import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
@@ -40,39 +41,6 @@ import {
import { PerformerSelect } from "./PerformerSelect";
import { uniq } from "lodash-es";
-/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
-type CustomFieldScrapeResults = Map>;
-
-// There are a bunch of similar functions in PerformerScrapeDialog, but since we don't support
-// scraping custom fields, this one is only needed here. The `renderScraped` naming is kept the same
-// for consistency.
-function renderScrapedCustomFieldRows(
- results: CustomFieldScrapeResults,
- onChange: (newCustomFields: CustomFieldScrapeResults) => void
-) {
- return (
- <>
- {Array.from(results.entries()).map(([field, result]) => {
- const fieldName = `custom_${field}`;
- return (
- {
- const newResults = new Map(results);
- newResults.set(field, newResult);
- onChange(newResults);
- }}
- />
- );
- })}
- >
- );
-}
-
type MergeOptions = {
values: GQL.PerformerUpdateInput;
};
@@ -134,8 +102,11 @@ const PerformerMergeDetails: React.FC = ({
const [fakeTits, setFakeTits] = useState>(
new ScrapeResult(dest.fake_tits)
);
- const [careerLength, setCareerLength] = useState>(
- new ScrapeResult(dest.career_length)
+ const [careerStart, setCareerStart] = useState>(
+ new ScrapeResult(dest.career_start?.toString())
+ );
+ const [careerEnd, setCareerEnd] = useState>(
+ new ScrapeResult(dest.career_end?.toString())
);
const [tattoos, setTattoos] = useState>(
new ScrapeResult(dest.tattoos)
@@ -296,11 +267,18 @@ const PerformerMergeDetails: React.FC = ({
!dest.fake_tits
)
);
- setCareerLength(
+ setCareerStart(
new ScrapeResult(
- dest.career_length,
- sources.find((s) => s.career_length)?.career_length,
- !dest.career_length
+ dest.career_start?.toString(),
+ sources.find((s) => s.career_start)?.career_start?.toString(),
+ !dest.career_start
+ )
+ );
+ setCareerEnd(
+ new ScrapeResult(
+ dest.career_end?.toString(),
+ sources.find((s) => s.career_end)?.career_end?.toString(),
+ !dest.career_end
)
);
setTattoos(
@@ -410,7 +388,8 @@ const PerformerMergeDetails: React.FC = ({
penisLength,
measurements,
fakeTits,
- careerLength,
+ careerStart,
+ careerEnd,
tattoos,
piercings,
urls,
@@ -436,7 +415,8 @@ const PerformerMergeDetails: React.FC = ({
penisLength,
measurements,
fakeTits,
- careerLength,
+ careerStart,
+ careerEnd,
tattoos,
piercings,
urls,
@@ -552,10 +532,16 @@ const PerformerMergeDetails: React.FC = ({
onChange={(value) => setFakeTits(value)}
/>
setCareerLength(value)}
+ field="career_start"
+ title={intl.formatMessage({ id: "career_start" })}
+ result={careerStart}
+ onChange={(value) => setCareerStart(value)}
+ />
+ setCareerEnd(value)}
/>
= ({
result={image}
onChange={(value) => setImage(value)}
/>
- {hasCustomFieldValues &&
- renderScrapedCustomFieldRows(customFields, (newCustomFields) =>
- setCustomFields(newCustomFields)
- )}
+ {hasCustomFieldValues && (
+ setCustomFields(newCustomFields)}
+ />
+ )}
>
);
}
@@ -642,7 +630,12 @@ const PerformerMergeDetails: React.FC = ({
: undefined,
measurements: measurements.getNewValue(),
fake_tits: fakeTits.getNewValue(),
- career_length: careerLength.getNewValue(),
+ career_start: careerStart.getNewValue()
+ ? parseInt(careerStart.getNewValue()!)
+ : undefined,
+ career_end: careerEnd.getNewValue()
+ ? parseInt(careerEnd.getNewValue()!)
+ : undefined,
tattoos: tattoos.getNewValue(),
piercings: piercings.getNewValue(),
urls: urls.getNewValue(),
diff --git a/ui/v2.5/src/components/Performers/Performers.tsx b/ui/v2.5/src/components/Performers/Performers.tsx
index d240ce988..7b6e32b8f 100644
--- a/ui/v2.5/src/components/Performers/Performers.tsx
+++ b/ui/v2.5/src/components/Performers/Performers.tsx
@@ -4,11 +4,11 @@ import { Helmet } from "react-helmet";
import { useTitleProps } from "src/hooks/title";
import Performer from "./PerformerDetails/Performer";
import PerformerCreate from "./PerformerDetails/PerformerCreate";
-import { PerformerList } from "./PerformerList";
+import { FilteredPerformerList } from "./PerformerList";
import { View } from "../List/views";
const Performers: React.FC = () => {
- return ;
+ return ;
};
const PerformerRoutes: React.FC = () => {
diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss
index 17ca3a737..54a010e50 100644
--- a/ui/v2.5/src/components/Performers/styles.scss
+++ b/ui/v2.5/src/components/Performers/styles.scss
@@ -68,7 +68,8 @@
.collapsed {
.detail-item.tattoos,
.detail-item.piercings,
- .detail-item.career_length,
+ .detail-item.career_start,
+ .detail-item.career_end,
.detail-item.details,
.detail-item.tags,
.detail-item.stash_ids {
diff --git a/ui/v2.5/src/components/ScenePlayer/util.ts b/ui/v2.5/src/components/ScenePlayer/util.ts
index 8c6fb8010..21ed99b62 100644
--- a/ui/v2.5/src/components/ScenePlayer/util.ts
+++ b/ui/v2.5/src/components/ScenePlayer/util.ts
@@ -1,7 +1,27 @@
-import videojs from "video.js";
+import videojs, { VideoJsPlayer } from "video.js";
export const VIDEO_PLAYER_ID = "VideoJsPlayer";
export const getPlayer = () => videojs.getPlayer(VIDEO_PLAYER_ID);
export const getPlayerPosition = () => getPlayer()?.currentTime();
+
+export type AbLoopOptions = {
+ start: number;
+ end: number | false;
+ enabled?: boolean;
+};
+
+export type AbLoopPluginApi = {
+ getOptions: () => AbLoopOptions;
+ setOptions: (options: AbLoopOptions) => void;
+};
+
+export const getAbLoopPlugin = () => {
+ const player = getPlayer();
+ if (!player) return null;
+ const { abLoopPlugin } = player as VideoJsPlayer & {
+ abLoopPlugin?: AbLoopPluginApi;
+ };
+ return abLoopPlugin ?? null;
+};
diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx
index 143daca4f..8ecb6e557 100644
--- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx
+++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx
@@ -67,6 +67,8 @@ export const PreviewScrubber: React.FC = ({
const clientRect = imageParent.getBoundingClientRect();
const scale = scaleToFit(sprite, clientRect);
+ const spriteSheet = new Image();
+ spriteSheet.src = sprite.url;
setStyle({
backgroundPosition: `${-sprite.x}px ${-sprite.y}px`,
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx
index 11c805ec6..d5a32fc31 100644
--- a/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx
+++ b/ui/v2.5/src/components/Scenes/SceneDetails/PrimaryTags.tsx
@@ -4,18 +4,24 @@ import * as GQL from "src/core/generated-graphql";
import { Button, Badge, Card } from "react-bootstrap";
import TextUtils from "src/utils/text";
import { markerTitle } from "src/core/markers";
+import { useConfigurationContext } from "src/hooks/Config";
interface IPrimaryTags {
sceneMarkers: GQL.SceneMarkerDataFragment[];
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
+ onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void;
onEdit: (marker: GQL.SceneMarkerDataFragment) => void;
}
export const PrimaryTags: React.FC = ({
sceneMarkers,
onClickMarker,
+ onLoopMarker,
onEdit,
}) => {
+ const { configuration } = useConfigurationContext();
+ const showAbLoopControls = configuration?.ui?.showAbLoopControls;
+
if (!sceneMarkers?.length) return ;
const primaryTagNames: Record = {};
@@ -52,10 +58,21 @@ export const PrimaryTags: React.FC = ({
-
- {TextUtils.formatTimestampRange(
- marker.seconds,
- marker.end_seconds ?? undefined
+
+
+ {TextUtils.formatTimestampRange(
+ marker.seconds,
+ marker.end_seconds ?? undefined
+ )}
+
+ {showAbLoopControls && marker.end_seconds != null && (
+
)}
{tags}
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx
index ee38ebd47..435b9dce2 100644
--- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx
+++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx
@@ -32,7 +32,10 @@ import { ListFilterModel } from "src/models/list-filter/filter";
import Mousetrap from "mousetrap";
import { OrganizedButton } from "./OrganizedButton";
import { useConfigurationContext } from "src/hooks/Config";
-import { getPlayerPosition } from "src/components/ScenePlayer/util";
+import {
+ getAbLoopPlugin,
+ getPlayerPosition,
+} from "src/components/ScenePlayer/util";
import {
faEllipsisV,
faChevronRight,
@@ -311,9 +314,53 @@ const ScenePage: React.FC
= PatchComponent("ScenePage", (props) => {
};
function onClickMarker(marker: GQL.SceneMarkerDataFragment) {
+ const abLoopPlugin = getAbLoopPlugin();
+ const opts = abLoopPlugin?.getOptions();
+ const start = opts?.start;
+ const end = opts?.end;
+
+ const hasLoopRange =
+ opts?.enabled &&
+ typeof start === "number" &&
+ typeof end === "number" &&
+ Number.isFinite(start) &&
+ Number.isFinite(end);
+
+ if (
+ abLoopPlugin &&
+ opts &&
+ hasLoopRange &&
+ (marker.seconds < Math.min(start as number, end as number) ||
+ marker.seconds > Math.max(start as number, end as number))
+ ) {
+ abLoopPlugin.setOptions({
+ ...opts,
+ enabled: false,
+ });
+ }
+
setTimestamp(marker.seconds);
}
+ function onLoopMarker(marker: GQL.SceneMarkerDataFragment) {
+ if (marker.end_seconds == null) return;
+
+ setTimestamp(marker.seconds);
+ const start = Math.min(marker.seconds, marker.end_seconds);
+ const end = Math.max(marker.seconds, marker.end_seconds);
+ const abLoopPlugin = getAbLoopPlugin();
+ const opts = abLoopPlugin?.getOptions();
+
+ if (opts && abLoopPlugin) {
+ abLoopPlugin.setOptions({
+ ...opts,
+ start,
+ end,
+ enabled: true,
+ });
+ }
+ }
+
async function onRescan() {
await mutateMetadataScan({
paths: [objectPath(scene)],
@@ -415,7 +462,7 @@ const ScenePage: React.FC = PatchComponent("ScenePage", (props) => {
className="bg-secondary text-white"
onClick={() => setIsGenerateDialogOpen(true)}
>
-
+ …
= PatchComponent("ScenePage", (props) => {
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx
index ef1a2e7e1..a2bad2f8e 100644
--- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx
+++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkerForm.tsx
@@ -11,7 +11,10 @@ import {
} from "src/core/StashService";
import { DurationInput } from "src/components/Shared/DurationInput";
import { MarkerTitleSuggest } from "src/components/Shared/Select";
-import { getPlayerPosition } from "src/components/ScenePlayer/util";
+import {
+ getAbLoopPlugin,
+ getPlayerPosition,
+} from "src/components/ScenePlayer/util";
import { useToast } from "src/hooks/Toast";
import isEqual from "lodash-es/isEqual";
import { formikUtils } from "src/utils/form";
@@ -61,16 +64,39 @@ export const SceneMarkerForm: React.FC = ({
});
// useMemo to only run getPlayerPosition when the input marker actually changes
- const initialValues = useMemo(
- () => ({
+ const initialValues = useMemo(() => {
+ if (!marker) {
+ const abLoopPlugin = getAbLoopPlugin();
+ const opts = abLoopPlugin?.getOptions();
+ const start = opts?.start;
+ const end = opts?.end;
+ const hasAbLoop = Number.isFinite(start);
+
+ if (opts?.enabled && hasAbLoop) {
+ const current = Math.round(getPlayerPosition() ?? 0);
+ const rawEnd =
+ Number.isFinite(end) && (end as number) > 0 ? (end as number) : null;
+ const endSeconds =
+ rawEnd !== null ? rawEnd : Math.max(start as number, current);
+
+ return {
+ title: "",
+ seconds: start as number,
+ end_seconds: endSeconds,
+ primary_tag_id: "",
+ tag_ids: [],
+ };
+ }
+ }
+
+ return {
title: marker?.title ?? "",
seconds: marker?.seconds ?? Math.round(getPlayerPosition() ?? 0),
end_seconds: marker?.end_seconds ?? null,
primary_tag_id: marker?.primary_tag.id ?? "",
tag_ids: marker?.tags.map((tag) => tag.id) ?? [],
- }),
- [marker]
- );
+ };
+ }, [marker]);
type InputValues = yup.InferType;
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx
index 331c58c78..28a6e4d98 100644
--- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx
+++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMarkersPanel.tsx
@@ -11,12 +11,14 @@ interface ISceneMarkersPanelProps {
sceneId: string;
isVisible: boolean;
onClickMarker: (marker: GQL.SceneMarkerDataFragment) => void;
+ onLoopMarker: (marker: GQL.SceneMarkerDataFragment) => void;
}
export const SceneMarkersPanel: React.FC = ({
sceneId,
isVisible,
onClickMarker,
+ onLoopMarker,
}) => {
const { data, loading } = GQL.useFindSceneMarkerTagsQuery({
variables: { id: sceneId },
@@ -70,6 +72,7 @@ export const SceneMarkersPanel: React.FC = ({
diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx
index ff5237c9f..a0458c5ac 100644
--- a/ui/v2.5/src/components/Scenes/SceneList.tsx
+++ b/ui/v2.5/src/components/Scenes/SceneList.tsx
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo } from "react";
import cloneDeep from "lodash-es/cloneDeep";
import { FormattedMessage, useIntl } from "react-intl";
-import { useHistory } from "react-router-dom";
+import { useHistory, useLocation } from "react-router-dom";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import { queryFindScenes, useFindScenes } from "src/core/StashService";
@@ -19,12 +19,6 @@ import { SceneCardGrid } from "./SceneCardGrid";
import { TaggerContext } from "../Tagger/context";
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
import { useConfigurationContext } from "src/hooks/Config";
-import {
- faPencil,
- faPlay,
- faPlus,
- faTrash,
-} from "@fortawesome/free-solid-svg-icons";
import { SceneMergeModal } from "./SceneMergeDialog";
import { objectTitle } from "src/core/files";
import TextUtils from "src/utils/text";
@@ -32,10 +26,7 @@ import { View } from "../List/views";
import { FileSize } from "../Shared/FileSize";
import { LoadedContent } from "../List/PagedList";
import { useCloseEditDelete, useFilterOperations } from "../List/util";
-import {
- OperationDropdown,
- OperationDropdownItem,
-} from "../List/ListOperationButtons";
+import { ListOperations } from "../List/ListOperationButtons";
import { useFilteredItemList } from "../List/ItemList";
import {
Sidebar,
@@ -46,20 +37,14 @@ import {
} from "../Shared/Sidebar";
import { SidebarPerformersFilter } from "../List/Filters/PerformersFilter";
import { SidebarStudiosFilter } from "../List/Filters/StudiosFilter";
-import { PerformersCriterionOption } from "src/models/list-filter/criteria/performers";
-import { StudiosCriterionOption } from "src/models/list-filter/criteria/studios";
-import { TagsCriterionOption } from "src/models/list-filter/criteria/tags";
import { SidebarTagsFilter } from "../List/Filters/TagsFilter";
import cx from "classnames";
-import { RatingCriterionOption } from "src/models/list-filter/criteria/rating";
import { SidebarRatingFilter } from "../List/Filters/RatingFilter";
import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organized";
import { HasMarkersCriterionOption } from "src/models/list-filter/criteria/has-markers";
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
-import {
- DurationCriterionOption,
- PerformerAgeCriterionOption,
-} from "src/models/list-filter/scenes";
+import { PerformerAgeCriterionOption } from "src/models/list-filter/scenes";
+import { SidebarDuplicateFilter } from "../List/Filters/DuplicateFilter";
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter";
import {
@@ -68,8 +53,7 @@ import {
} from "../List/Filters/FilterSidebar";
import { PatchComponent, PatchContainerComponent } from "src/patch";
import { Pagination, PaginationIndex } from "../List/Pagination";
-import { Button, ButtonGroup } from "react-bootstrap";
-import { Icon } from "../Shared/Icon";
+import { Button } from "react-bootstrap";
import useFocus from "src/utils/focus";
import { useZoomKeybinds } from "../List/ZoomSlider";
import { FilteredListToolbar } from "../List/FilteredListToolbar";
@@ -200,59 +184,65 @@ const SceneList: React.FC<{
selectedIds: Set
;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
-}> = ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => {
- const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
+}> = PatchComponent(
+ "SceneList",
+ ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => {
+ const queue = useMemo(
+ () => SceneQueue.fromListFilterModel(filter),
+ [filter]
+ );
+
+ if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) {
+ return null;
+ }
+
+ if (filter.displayMode === DisplayMode.Grid) {
+ return (
+
+ );
+ }
+ if (filter.displayMode === DisplayMode.List) {
+ return (
+
+ );
+ }
+ if (filter.displayMode === DisplayMode.Wall) {
+ return (
+
+ );
+ }
+ if (filter.displayMode === DisplayMode.Tagger) {
+ return (
+
+ );
+ }
- if (scenes.length === 0 && filter.displayMode !== DisplayMode.Tagger) {
return null;
}
-
- if (filter.displayMode === DisplayMode.Grid) {
- return (
-
- );
- }
- if (filter.displayMode === DisplayMode.List) {
- return (
-
- );
- }
- if (filter.displayMode === DisplayMode.Wall) {
- return (
-
- );
- }
- if (filter.displayMode === DisplayMode.Tagger) {
- return (
-
- );
- }
-
- return null;
-};
+);
const ScenesFilterSidebarSections = PatchContainerComponent(
"FilteredSceneList.SidebarSections"
@@ -298,48 +288,23 @@ const SidebarContent: React.FC<{
{!hideStudios && (
}
- data-type={StudiosCriterionOption.type}
- option={StudiosCriterionOption}
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
- sectionID="studios"
/>
)}
}
- data-type={PerformersCriterionOption.type}
- option={PerformersCriterionOption}
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
- sectionID="performers"
/>
}
- data-type={TagsCriterionOption.type}
- option={TagsCriterionOption}
filter={filter}
setFilter={setFilter}
filterHook={filterHook}
- sectionID="tags"
- />
- }
- data-type={RatingCriterionOption.type}
- option={RatingCriterionOption}
- filter={filter}
- setFilter={setFilter}
- sectionID="rating"
- />
- }
- option={DurationCriterionOption}
- filter={filter}
- setFilter={setFilter}
- sectionID="duration"
/>
+
+
}
data-type={HasMarkersCriterionOption.type}
@@ -356,6 +321,12 @@ const SidebarContent: React.FC<{
setFilter={setFilter}
sectionID="organized"
/>
+ }
+ filter={filter}
+ setFilter={setFilter}
+ sectionID="duplicated"
+ />
}
option={PerformerAgeCriterionOption}
@@ -374,102 +345,6 @@ const SidebarContent: React.FC<{
);
};
-interface IOperations {
- text: string;
- onClick: () => void;
- isDisplayed?: () => boolean;
- className?: string;
-}
-
-const SceneListOperations: React.FC<{
- items: number;
- hasSelection: boolean;
- operations: IOperations[];
- onEdit: () => void;
- onDelete: () => void;
- onPlay: () => void;
- onCreateNew: () => void;
-}> = PatchComponent(
- "SceneListOperations",
- ({
- items,
- hasSelection,
- operations,
- onEdit,
- onDelete,
- onPlay,
- onCreateNew,
- }) => {
- const intl = useIntl();
-
- return (
-
-
- {!!items && (
-
- )}
- {!hasSelection && (
-
- )}
-
- {hasSelection && (
- <>
-
-
- >
- )}
-
-
- {operations.map((o) => {
- if (o.isDisplayed && !o.isDisplayed()) {
- return null;
- }
-
- return (
-
- );
- })}
-
-
-
- );
- }
-);
-
interface IFilteredScenes {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultSort?: string;
@@ -478,362 +353,379 @@ interface IFilteredScenes {
fromGroupId?: string;
}
-export const FilteredSceneList = (props: IFilteredScenes) => {
- const intl = useIntl();
- const history = useHistory();
+export const FilteredSceneList = PatchComponent(
+ "FilteredSceneList",
+ (props: IFilteredScenes) => {
+ const intl = useIntl();
+ const history = useHistory();
+ const location = useLocation();
- const searchFocus = useFocus();
+ const searchFocus = useFocus();
- const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
+ const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
- // States
- const {
- showSidebar,
- setShowSidebar,
- loading: sidebarStateLoading,
- sectionOpen,
- setSectionOpen,
- } = useSidebarState(view);
+ // States
+ const {
+ showSidebar,
+ setShowSidebar,
+ loading: sidebarStateLoading,
+ sectionOpen,
+ setSectionOpen,
+ } = useSidebarState(view);
- const { filterState, queryResult, modalState, listSelect, showEditFilter } =
- useFilteredItemList({
- filterStateProps: {
- filterMode: GQL.FilterMode.Scenes,
- defaultSort,
- view,
- useURL: alterQuery,
- },
- queryResultProps: {
- useResult: useFindScenes,
- getCount: (r) => r.data?.findScenes.count ?? 0,
- getItems: (r) => r.data?.findScenes.scenes ?? [],
- filterHook,
- },
+ const { filterState, queryResult, modalState, listSelect, showEditFilter } =
+ useFilteredItemList({
+ filterStateProps: {
+ filterMode: GQL.FilterMode.Scenes,
+ defaultSort,
+ view,
+ useURL: alterQuery,
+ },
+ queryResultProps: {
+ useResult: useFindScenes,
+ getCount: (r) => r.data?.findScenes.count ?? 0,
+ getItems: (r) => r.data?.findScenes.scenes ?? [],
+ filterHook,
+ },
+ });
+
+ const { filter, setFilter } = filterState;
+
+ const { effectiveFilter, result, cachedResult, items, totalCount } =
+ queryResult;
+
+ const {
+ selectedIds,
+ selectedItems,
+ onSelectChange,
+ onSelectAll,
+ onSelectNone,
+ onInvertSelection,
+ hasSelection,
+ } = listSelect;
+
+ const { modal, showModal, closeModal } = modalState;
+
+ // Utility hooks
+ const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
+ filter,
+ setFilter,
});
- const { filter, setFilter } = filterState;
+ useAddKeybinds(filter, totalCount);
+ useFilteredSidebarKeybinds({
+ showSidebar,
+ setShowSidebar,
+ });
- const { effectiveFilter, result, cachedResult, items, totalCount } =
- queryResult;
+ const onCloseEditDelete = useCloseEditDelete({
+ closeModal,
+ onSelectNone,
+ result,
+ });
- const {
- selectedIds,
- selectedItems,
- onSelectChange,
- onSelectAll,
- onSelectNone,
- onInvertSelection,
- hasSelection,
- } = listSelect;
+ const onEdit = useCallback(() => {
+ showModal(
+
+ );
+ }, [showModal, selectedItems, onCloseEditDelete]);
- const { modal, showModal, closeModal } = modalState;
+ const onDelete = useCallback(() => {
+ showModal(
+
+ );
+ }, [showModal, selectedItems, onCloseEditDelete]);
- // Utility hooks
- const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
- filter,
- setFilter,
- });
+ useEffect(() => {
+ Mousetrap.bind("e", () => {
+ if (hasSelection) {
+ onEdit?.();
+ }
+ });
- useAddKeybinds(filter, totalCount);
- useFilteredSidebarKeybinds({
- showSidebar,
- setShowSidebar,
- });
+ Mousetrap.bind("d d", () => {
+ if (hasSelection) {
+ onDelete?.();
+ }
+ });
- const onCloseEditDelete = useCloseEditDelete({
- closeModal,
- onSelectNone,
- result,
- });
+ return () => {
+ Mousetrap.unbind("e");
+ Mousetrap.unbind("d d");
+ };
+ }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]);
+ useZoomKeybinds({
+ zoomIndex: filter.zoomIndex,
+ onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),
+ });
- const onEdit = useCallback(() => {
- showModal(
-
+ const metadataByline = useMemo(() => {
+ if (cachedResult.loading) return null;
+
+ return renderMetadataByline(cachedResult) ?? null;
+ }, [cachedResult]);
+
+ const queue = useMemo(
+ () => SceneQueue.fromListFilterModel(filter),
+ [filter]
);
- }, [showModal, selectedItems, onCloseEditDelete]);
- const onDelete = useCallback(() => {
- showModal(
-
- );
- }, [showModal, selectedItems, onCloseEditDelete]);
+ const playRandom = usePlayRandom(effectiveFilter, totalCount);
+ const playSelected = usePlaySelected(selectedIds);
+ const playFirst = usePlayFirst();
- useEffect(() => {
- Mousetrap.bind("e", () => {
- if (hasSelection) {
- onEdit?.();
+ function onCreateNew() {
+ let queryParam = new URLSearchParams(location.search).get("q");
+ let newPath = "/scenes/new";
+ if (queryParam) {
+ newPath += "?q=" + encodeURIComponent(queryParam);
}
- });
-
- Mousetrap.bind("d d", () => {
- if (hasSelection) {
- onDelete?.();
- }
- });
-
- return () => {
- Mousetrap.unbind("e");
- Mousetrap.unbind("d d");
- };
- }, [onSelectAll, onSelectNone, hasSelection, onEdit, onDelete]);
- useZoomKeybinds({
- zoomIndex: filter.zoomIndex,
- onChangeZoom: (zoom) => setFilter(filter.setZoom(zoom)),
- });
-
- const metadataByline = useMemo(() => {
- if (cachedResult.loading) return null;
-
- return renderMetadataByline(cachedResult) ?? null;
- }, [cachedResult]);
-
- const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
-
- const playRandom = usePlayRandom(effectiveFilter, totalCount);
- const playSelected = usePlaySelected(selectedIds);
- const playFirst = usePlayFirst();
-
- function onCreateNew() {
- history.push("/scenes/new");
- }
-
- function onPlay() {
- if (items.length === 0) {
- return;
+ history.push(newPath);
}
- // if there are selected items, play those
- if (hasSelection) {
- playSelected();
- return;
+ function onPlay() {
+ if (items.length === 0) {
+ return;
+ }
+
+ // if there are selected items, play those
+ if (hasSelection) {
+ playSelected();
+ return;
+ }
+
+ // otherwise, play the first item in the list
+ const sceneID = items[0].id;
+ playFirst(queue, sceneID, 0);
}
- // otherwise, play the first item in the list
- const sceneID = items[0].id;
- playFirst(queue, sceneID, 0);
- }
+ function onExport(all: boolean) {
+ showModal(
+ closeModal()}
+ />
+ );
+ }
- function onExport(all: boolean) {
- showModal(
- closeModal()}
+ function onMerge() {
+ const selected =
+ selectedItems.map((s) => {
+ return {
+ id: s.id,
+ title: objectTitle(s),
+ };
+ }) ?? [];
+ showModal(
+ {
+ closeModal();
+ if (mergedID) {
+ history.push(`/scenes/${mergedID}`);
+ }
+ }}
+ show
+ />
+ );
+ }
+
+ const otherOperations = [
+ {
+ text: intl.formatMessage({ id: "actions.play" }),
+ onClick: () => onPlay(),
+ isDisplayed: () => items.length > 0,
+ className: "play-item",
+ },
+ {
+ text: intl.formatMessage(
+ { id: "actions.create_entity" },
+ { entityType: intl.formatMessage({ id: "scene" }) }
+ ),
+ onClick: () => onCreateNew(),
+ isDisplayed: () => !hasSelection,
+ className: "create-new-item",
+ },
+ {
+ text: intl.formatMessage({ id: "actions.select_all" }),
+ onClick: () => onSelectAll(),
+ isDisplayed: () => totalCount > 0,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.select_none" }),
+ onClick: () => onSelectNone(),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.invert_selection" }),
+ onClick: () => onInvertSelection(),
+ isDisplayed: () => totalCount > 0,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.play_random" }),
+ onClick: playRandom,
+ isDisplayed: () => totalCount > 1,
+ },
+ {
+ text: `${intl.formatMessage({ id: "actions.generate" })}…`,
+ onClick: () =>
+ showModal(
+ closeModal()}
+ />
+ ),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: `${intl.formatMessage({ id: "actions.identify" })}…`,
+ onClick: () =>
+ showModal(
+ closeModal()}
+ />
+ ),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: `${intl.formatMessage({ id: "actions.merge" })}…`,
+ onClick: () => onMerge(),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.export" }),
+ onClick: () => onExport(false),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.export_all" }),
+ onClick: () => onExport(true),
+ },
+ ];
+
+ // render
+ if (sidebarStateLoading) return null;
+
+ const operations = (
+
);
- }
- function onMerge() {
- const selected =
- selectedItems.map((s) => {
- return {
- id: s.id,
- title: objectTitle(s),
- };
- }) ?? [];
- showModal(
- {
- closeModal();
- if (mergedID) {
- history.push(`/scenes/${mergedID}`);
- }
- }}
- show
- />
- );
- }
+ return (
+
+
+ {modal}
- const otherOperations = [
- {
- text: intl.formatMessage({ id: "actions.play" }),
- onClick: () => onPlay(),
- isDisplayed: () => items.length > 0,
- className: "play-item",
- },
- {
- text: intl.formatMessage(
- { id: "actions.create_entity" },
- { entityType: intl.formatMessage({ id: "scene" }) }
- ),
- onClick: () => onCreateNew(),
- isDisplayed: () => !hasSelection,
- className: "create-new-item",
- },
- {
- text: intl.formatMessage({ id: "actions.select_all" }),
- onClick: () => onSelectAll(),
- isDisplayed: () => totalCount > 0,
- },
- {
- text: intl.formatMessage({ id: "actions.select_none" }),
- onClick: () => onSelectNone(),
- isDisplayed: () => hasSelection,
- },
- {
- text: intl.formatMessage({ id: "actions.invert_selection" }),
- onClick: () => onInvertSelection(),
- isDisplayed: () => totalCount > 0,
- },
- {
- text: intl.formatMessage({ id: "actions.play_random" }),
- onClick: playRandom,
- isDisplayed: () => totalCount > 1,
- },
- {
- text: `${intl.formatMessage({ id: "actions.generate" })}…`,
- onClick: () =>
- showModal(
-
closeModal()}
- />
- ),
- isDisplayed: () => hasSelection,
- },
- {
- text: `${intl.formatMessage({ id: "actions.identify" })}…`,
- onClick: () =>
- showModal(
- closeModal()}
- />
- ),
- isDisplayed: () => hasSelection,
- },
- {
- text: `${intl.formatMessage({ id: "actions.merge" })}…`,
- onClick: () => onMerge(),
- isDisplayed: () => hasSelection,
- },
- {
- text: intl.formatMessage({ id: "actions.export" }),
- onClick: () => onExport(false),
- isDisplayed: () => hasSelection,
- },
- {
- text: intl.formatMessage({ id: "actions.export_all" }),
- onClick: () => onExport(true),
- },
- ];
-
- // render
- if (sidebarStateLoading) return null;
-
- const operations = (
-
- );
-
- return (
-
-
- {modal}
-
-
-
- setShowSidebar(false)}>
- setShowSidebar(false)}
- count={cachedResult.loading ? undefined : totalCount}
- focus={searchFocus}
- />
-
- setShowSidebar(!showSidebar)}
- >
-
-
- showEditFilter(c.criterionOption.type)}
- onRemoveCriterion={removeCriterion}
- onRemoveAll={clearAllCriteria}
- />
-
-
-
setFilter(filter.changePage(page))}
+
+
+ setShowSidebar(false)}>
+ setShowSidebar(false)}
+ count={cachedResult.loading ? undefined : totalCount}
+ focus={searchFocus}
/>
-
+ setShowSidebar(!showSidebar)}
+ >
+
-
-
-
+ showEditFilter(c.criterionOption.type)
+ }
+ onRemoveCriterion={removeCriterion}
+ onRemoveAll={clearAllCriteria}
/>
-
- {totalCount > filter.itemsPerPage && (
-
-
+
+
setFilter(filter.changePage(page))}
+ />
+
- )}
-
-
-
-
-
- );
-};
+
+
+
+
+
+ {totalCount > filter.itemsPerPage && (
+
+ )}
+
+
+
+
+
+ );
+ }
+);
export default FilteredSceneList;
diff --git a/ui/v2.5/src/components/Settings/Settings.tsx b/ui/v2.5/src/components/Settings/Settings.tsx
index 4c2b02455..86a781445 100644
--- a/ui/v2.5/src/components/Settings/Settings.tsx
+++ b/ui/v2.5/src/components/Settings/Settings.tsx
@@ -18,6 +18,8 @@ import { SettingsContext, useSettings } from "./context";
import { SettingsLibraryPanel } from "./SettingsLibraryPanel";
import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
import Changelog from "../Changelog/Changelog";
+import { TroubleshootingModeButton } from "../TroubleshootingMode/TroubleshootingModeButton";
+import { useTroubleshootingMode } from "../TroubleshootingMode/useTroubleshootingMode";
const validTabs = [
"tasks",
@@ -43,6 +45,7 @@ function isTabKey(tab: string | null): tab is TabKey {
const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => {
const { advancedMode, setAdvancedMode } = useSettings();
+ const { isActive: troubleshootingModeActive } = useTroubleshootingMode();
const titleProps = useTitleProps({ id: "settings" });
@@ -148,6 +151,7 @@ const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => {
/>
+ {!troubleshootingModeActive && }
diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
index 34fb634b2..446ad09a1 100644
--- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
+++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
@@ -427,6 +427,44 @@ export const SettingsConfigurationPanel: React.FC = () => {
/>
+
+ saveGeneral({ spriteScreenshotSize: v })}
+ />
+ saveGeneral({ useCustomSpriteInterval: v })}
+ />
+ saveGeneral({ spriteInterval: v })}
+ />
+ saveGeneral({ minimumSprites: v })}
+ />
+ saveGeneral({ maximumSprites: v })}
+ />
+
+
= ({
);
};
+const BackupDialog: React.FC<{
+ onClose: (
+ confirmed?: boolean,
+ download?: boolean,
+ includeBlobs?: boolean
+ ) => void;
+}> = ({ onClose }) => {
+ const intl = useIntl();
+ const { configuration } = useConfigurationContext();
+
+ const includeBlobsDefault =
+ configuration?.general.blobsStorage === GQL.BlobsStorageType.Filesystem;
+ const backupDir =
+ configuration.general.backupDirectoryPath ||
+ `<${intl.formatMessage({
+ id: "config.general.backup_directory_path.heading",
+ })}>`;
+
+ const [download, setDownload] = useState(false);
+ const [includeBlobs, setIncludeBlobs] = useState(includeBlobsDefault);
+
+ let msg;
+ if (!includeBlobs) {
+ msg = intl.formatMessage(
+ { id: "config.tasks.backup_database.sqlite" },
+ {
+ filename_format: (
+ [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]
+ ),
+ }
+ );
+ } else {
+ msg = intl.formatMessage(
+ { id: "config.tasks.backup_database.zip" },
+ {
+ filename_format: (
+
+ [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS].zip
+
+ ),
+ }
+ );
+ }
+
+ const warning =
+ includeBlobs !== includeBlobsDefault ? (
+
+
+
+
+ ) : null;
+
+ const acceptID = download
+ ? "config.tasks.backup_database.download"
+ : "actions.backup";
+
+ return (
+ onClose(true, download, includeBlobs),
+ }}
+ cancel={{
+ onClick: () => onClose(),
+ variant: "secondary",
+ }}
+ >
+
+
+
+
+
+ setDownload(false)}
+ label={intl.formatMessage(
+ {
+ id: "config.tasks.backup_database.to_directory",
+ },
+ {
+ directory: {backupDir},
+ }
+ )}
+ />
+
+ setDownload(true)}
+ label={intl.formatMessage({
+ id: "config.tasks.backup_database.download",
+ })}
+ />
+
+
+
+ setIncludeBlobs(v)}
+ // if includeBlobsDefault is false, then blobs are in the database
+ disabled={!includeBlobsDefault}
+ />
+
+
+
{msg}
+ {warning}
+
+
+ );
+};
+
interface IDataManagementTasks {
setIsBackupRunning: (v: boolean) => void;
setIsAnonymiseRunning: (v: boolean) => void;
@@ -167,6 +288,7 @@ export const DataManagementTasks: React.FC = ({
const [dialogOpen, setDialogOpenState] = useState({
importAlert: false,
import: false,
+ backup: false,
clean: false,
cleanAlert: false,
cleanGenerated: false,
@@ -344,11 +466,12 @@ export const DataManagementTasks: React.FC = ({
}
}
- async function onBackup(download?: boolean) {
+ async function onBackup(download?: boolean, includeBlobs?: boolean) {
try {
setIsBackupRunning(true);
const ret = await mutateBackupDatabase({
download,
+ includeBlobs,
});
// download the result
@@ -439,6 +562,17 @@ export const DataManagementTasks: React.FC = ({
}}
/>
)}
+ {dialogOpen.backup && (
+ {
+ if (confirmed) {
+ onBackup(download, includeBlobs);
+ }
+
+ setDialogOpen({ backup: false });
+ }}
+ />
+ )}
@@ -555,39 +689,25 @@ export const DataManagementTasks: React.FC
= ({
- [origFilename].sqlite.[schemaVersion].[YYYYMMDD_HHMMSS]
-
- ),
- }
- )}
+ heading={
+ <>
+
+
+
+
+ >
+ }
+ subHeading={intl.formatMessage({
+ id: "config.tasks.backup_database.description",
+ })}
>
-
-
-
-
diff --git a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx
index ee126d41e..c68b6d5eb 100644
--- a/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx
+++ b/ui/v2.5/src/components/Settings/Tasks/GenerateOptions.tsx
@@ -7,7 +7,7 @@ import {
} from "../GeneratePreviewOptions";
interface IGenerateOptions {
- type?: "scene" | "image";
+ type?: "scene" | "image" | "gallery";
selection?: boolean;
options: GQL.GenerateMetadataInput;
setOptions: (s: GQL.GenerateMetadataInput) => void;
@@ -27,7 +27,7 @@ export const GenerateOptions: React.FC = ({
}
const showSceneOptions = !type || type === "scene";
- const showImageOptions = !type || type === "image";
+ const showImageOptions = !type || type === "image" || type === "gallery";
return (
<>
@@ -167,6 +167,13 @@ export const GenerateOptions: React.FC = ({
headingID="dialogs.scene_gen.image_thumbnails"
onChange={(v) => setOptions({ imageThumbnails: v })}
/>
+ setOptions({ imagePhashes: v })}
+ />
>
)}
= ({
scanGenerateSprites,
scanGeneratePhashes,
scanGenerateThumbnails,
+ scanGenerateImagePhashes,
scanGenerateClipPreviews,
rescan,
} = options;
@@ -72,6 +73,13 @@ export const ScanOptions: React.FC = ({
headingID="config.tasks.generate_thumbnails_during_scan"
onChange={(v) => setOptions({ scanGenerateThumbnails: v })}
/>
+ setOptions({ scanGenerateImagePhashes: v })}
+ />
= ({
return (
onClose()} title="">
- Select Directory
+
+
+
{
+ if (ScreenUtils.isMobile()) {
+ return;
+ }
+
if (
- !containerWidth ||
zoomIndex === undefined ||
zoomIndex < 0 ||
- zoomIndex >= zoomWidths.length ||
- ScreenUtils.isMobile()
+ zoomIndex >= zoomWidths.length
)
return;
+ // use a default card width if we don't have the container width yet
+ if (!containerWidth) {
+ return zoomWidths[zoomIndex];
+ }
+
let zoomValue = zoomIndex;
const preferredCardWidth = zoomWidths[zoomValue];
let fittedCardWidth = calculateCardWidth(
diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx
index 88b79d87d..a0fe6489e 100644
--- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx
+++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialogRow.tsx
@@ -14,7 +14,7 @@ import { getCountryByISO } from "src/utils/country";
import { CountrySelect } from "../CountrySelect";
import { StringListInput } from "../StringListInput";
import { ImageSelector } from "../ImageSelector";
-import { ScrapeResult } from "./scrapeResult";
+import { CustomFieldScrapeResults, ScrapeResult } from "./scrapeResult";
import { ScrapeDialogContext } from "./ScrapeDialog";
function renderButtonIcon(selected: boolean) {
@@ -171,6 +171,70 @@ export const ScrapedInputGroupRow: React.FC = (
);
};
+interface IScrapedNumberInputProps {
+ isNew?: boolean;
+ placeholder?: string;
+ locked?: boolean;
+ result: ScrapeResult;
+ onChange?: (value: number) => void;
+}
+
+const ScrapedNumberInput: React.FC = (props) => {
+ return (
+ {
+ if (props.isNew && props.onChange) {
+ props.onChange(Number(e.target.value));
+ }
+ }}
+ className="bg-secondary text-white border-secondary"
+ type="number"
+ />
+ );
+};
+
+interface IScrapedNumberRowProps {
+ title: string;
+ field: string;
+ className?: string;
+ placeholder?: string;
+ result: ScrapeResult;
+ locked?: boolean;
+ onChange: (value: ScrapeResult) => void;
+}
+
+export const ScrapedNumberRow: React.FC = (props) => {
+ return (
+
+ }
+ newField={
+
+ props.onChange(props.result.cloneWithValue(value))
+ }
+ />
+ }
+ onChange={props.onChange}
+ />
+ );
+};
+
interface IScrapedStringListProps {
isNew?: boolean;
placeholder?: string;
@@ -431,3 +495,30 @@ export const ScrapedCountryRow: React.FC = ({
onChange={onChange}
/>
);
+
+export const ScrapedCustomFieldRows: React.FC<{
+ results: CustomFieldScrapeResults;
+ onChange: (newCustomFields: CustomFieldScrapeResults) => void;
+}> = ({ results, onChange }) => {
+ return (
+ <>
+ {Array.from(results.entries()).map(([field, result]) => {
+ const fieldName = `custom_${field}`;
+ return (
+ {
+ const newResults = new Map(results);
+ newResults.set(field, newResult);
+ onChange(newResults);
+ }}
+ />
+ );
+ })}
+ >
+ );
+};
diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts
index b9b88cef0..63d1c76c1 100644
--- a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts
+++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts
@@ -2,6 +2,9 @@ import lodashIsEqual from "lodash-es/isEqual";
import clone from "lodash-es/clone";
import { IHasStoredID } from "src/utils/data";
+/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+export type CustomFieldScrapeResults = Map>;
+
export class ScrapeResult {
public newValue?: T;
public originalValue?: T;
diff --git a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx
index 293a8dfb3..1c34dfc36 100644
--- a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx
+++ b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx
@@ -23,7 +23,13 @@ interface IListOperationProps {
onClose: (applied: boolean) => void;
}
-const studioFields = ["favorite", "rating100", "details", "ignore_auto_tag"];
+const studioFields = [
+ "favorite",
+ "rating100",
+ "details",
+ "ignore_auto_tag",
+ "organized",
+];
export const EditStudiosDialog: React.FC = (
props: IListOperationProps
@@ -236,6 +242,14 @@ export const EditStudiosDialog: React.FC = (
checked={updateInput.ignore_auto_tag ?? undefined}
/>
+
+
+ setUpdateField({ organized: checked })}
+ checked={updateInput.organized ?? undefined}
+ />
+
);
diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx
index 87c9b9528..839489182 100644
--- a/ui/v2.5/src/components/Studios/StudioCard.tsx
+++ b/ui/v2.5/src/components/Studios/StudioCard.tsx
@@ -7,13 +7,13 @@ import { PatchComponent } from "src/patch";
import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon";
import { TagLink } from "../Shared/TagLink";
-import { Button, ButtonGroup } from "react-bootstrap";
+import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
import { RatingBanner } from "../Shared/RatingBanner";
import { FavoriteIcon } from "../Shared/FavoriteIcon";
import { useStudioUpdate } from "src/core/StashService";
-import { faTag } from "@fortawesome/free-solid-svg-icons";
+import { faTag, faBox } from "@fortawesome/free-solid-svg-icons";
import { OCounterButton } from "../Shared/CountButton";
interface IProps {
@@ -185,6 +185,27 @@ export const StudioCard: React.FC = PatchComponent(
return ;
}
+ function maybeRenderOrganized() {
+ if (studio.organized) {
+ return (
+
+
+
+ }
+ placement="bottom"
+ >
+
+
+
+
+ );
+ }
+ }
+
function maybeRenderPopoverButtonGroup() {
if (
studio.scene_count ||
@@ -193,7 +214,8 @@ export const StudioCard: React.FC = PatchComponent(
studio.group_count ||
studio.performer_count ||
studio.o_counter ||
- studio.tags.length > 0
+ studio.tags.length > 0 ||
+ studio.organized
) {
return (
<>
@@ -206,6 +228,7 @@ export const StudioCard: React.FC = PatchComponent(
{maybeRenderPerformersPopoverButton()}
{maybeRenderTagPopoverButton()}
{maybeRenderOCounter()}
+ {maybeRenderOrganized()}
>
);
diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx
index 2edc53fe1..0096851e2 100644
--- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx
+++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx
@@ -49,6 +49,7 @@ import { AliasList } from "src/components/Shared/DetailsPage/AliasList";
import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage";
import { goBackOrReplace } from "src/utils/history";
import { OCounterButton } from "src/components/Shared/CountButton";
+import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
interface IProps {
studio: GQL.StudioDataFragment;
@@ -316,6 +317,28 @@ const StudioPage: React.FC = ({ studio, tabKey }) => {
}
}
+ const [organizedLoading, setOrganizedLoading] = useState(false);
+
+ async function onOrganizedClick() {
+ if (!studio.id) return;
+
+ setOrganizedLoading(true);
+ try {
+ await updateStudio({
+ variables: {
+ input: {
+ id: studio.id,
+ organized: !studio.organized,
+ },
+ },
+ });
+ } catch (e) {
+ Toast.error(e);
+ } finally {
+ setOrganizedLoading(false);
+ }
+ }
+
// set up hotkeys
useEffect(() => {
Mousetrap.bind("e", () => toggleEditing());
@@ -467,6 +490,11 @@ const StudioPage: React.FC = ({ studio, tabKey }) => {
favorite={studio.favorite}
onToggleFavorite={(v) => setFavorite(v)}
/>
+
diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx
index b6cd8b484..a69364a89 100644
--- a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx
+++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx
@@ -2,7 +2,7 @@ import React from "react";
import * as GQL from "src/core/generated-graphql";
import { ParentStudiosCriterion } from "src/models/list-filter/criteria/studios";
import { ListFilterModel } from "src/models/list-filter/filter";
-import { StudioList } from "../StudioList";
+import { FilteredStudioList } from "../StudioList";
import { View } from "src/components/List/views";
function useFilterHook(studio: GQL.StudioDataFragment) {
@@ -51,7 +51,7 @@ export const StudioChildrenPanel: React.FC = ({
const filterHook = useFilterHook(studio);
return (
- = ({
urls: yup.array(yup.string().required()).defined(),
details: yup.string().ensure(),
parent_id: yup.string().required().nullable(),
- aliases: yupUniqueAliases(intl, "name"),
+ aliases: yupRequiredStringArray(intl).defined(),
tag_ids: yup.array(yup.string().required()).defined(),
ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed().defined(),
diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx
index 340586b94..f5a1aba32 100644
--- a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx
+++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx
@@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
-import { GalleryList } from "src/components/Galleries/GalleryList";
+import { FilteredGalleryList } from "src/components/Galleries/GalleryList";
import { useStudioFilterHook } from "src/core/studios";
import { View } from "src/components/List/views";
@@ -17,7 +17,7 @@ export const StudioGalleriesPanel: React.FC = ({
}) => {
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return (
- = ({
}) => {
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return (
- = ({
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return (
- ;
+ onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
+ fromParent?: boolean;
+}> = PatchComponent(
+ "StudioList",
+ ({ studios, filter, selectedIds, onSelectChange, fromParent }) => {
+ if (studios.length === 0) {
+ return null;
+ }
-function getCount(result: GQL.FindStudiosQueryResult) {
- return result?.data?.findStudios?.count ?? 0;
-}
+ if (filter.displayMode === DisplayMode.Grid) {
+ return (
+
+ );
+ }
+ if (filter.displayMode === DisplayMode.List) {
+ return TODO
;
+ }
+ if (filter.displayMode === DisplayMode.Wall) {
+ return TODO
;
+ }
+ if (filter.displayMode === DisplayMode.Tagger) {
+ return ;
+ }
+
+ return null;
+ }
+);
+
+const StudioFilterSidebarSections = PatchContainerComponent(
+ "FilteredStudioList.SidebarSections"
+);
+
+const SidebarContent: React.FC<{
+ filter: ListFilterModel;
+ setFilter: (filter: ListFilterModel) => void;
+ filterHook?: (filter: ListFilterModel) => ListFilterModel;
+ view?: View;
+ sidebarOpen: boolean;
+ onClose?: () => void;
+ showEditFilter: (editingCriterion?: string) => void;
+ count?: number;
+ focus?: ReturnType;
+}> = ({
+ filter,
+ setFilter,
+ filterHook,
+ view,
+ showEditFilter,
+ sidebarOpen,
+ onClose,
+ count,
+ focus,
+}) => {
+ const showResultsId =
+ count !== undefined ? "actions.show_count_results" : "actions.show_results";
+
+ return (
+ <>
+
+
+
+
+
+ }
+ filter={filter}
+ setFilter={setFilter}
+ option={FavoriteStudioCriterionOption}
+ sectionID="favourite"
+ />
+
+
+
+
+
+ >
+ );
+};
interface IStudioList {
fromParent?: boolean;
@@ -37,147 +157,161 @@ interface IStudioList {
extraOperations?: IItemListOperation[];
}
-export const StudioList: React.FC = PatchComponent(
- "StudioList",
- ({ fromParent, filterHook, view, alterQuery, extraOperations = [] }) => {
+function useViewRandom(filter: ListFilterModel, count: number) {
+ const history = useHistory();
+
+ const viewRandom = useCallback(async () => {
+ // query for a random studio
+ if (count === 0) {
+ return;
+ }
+
+ const index = Math.floor(Math.random() * count);
+ const filterCopy = cloneDeep(filter);
+ filterCopy.itemsPerPage = 1;
+ filterCopy.currentPage = index + 1;
+ const singleResult = await queryFindStudios(filterCopy);
+ if (singleResult.data.findStudios.studios.length === 1) {
+ const { id } = singleResult.data.findStudios.studios[0];
+ // navigate to the studio page
+ history.push(`/studios/${id}`);
+ }
+ }, [history, filter, count]);
+
+ return viewRandom;
+}
+
+function useAddKeybinds(filter: ListFilterModel, count: number) {
+ const viewRandom = useViewRandom(filter, count);
+
+ useEffect(() => {
+ Mousetrap.bind("p r", () => {
+ viewRandom();
+ });
+
+ return () => {
+ Mousetrap.unbind("p r");
+ };
+ }, [viewRandom]);
+}
+
+export const FilteredStudioList = PatchComponent(
+ "FilteredStudioList",
+ (props: IStudioList) => {
const intl = useIntl();
- const history = useHistory();
- const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
- const [isExportAll, setIsExportAll] = useState(false);
- const filterMode = GQL.FilterMode.Studios;
+ const searchFocus = useFocus();
- const otherOperations = [
- ...extraOperations,
- {
- text: intl.formatMessage({ id: "actions.view_random" }),
- onClick: viewRandom,
- },
- {
- text: intl.formatMessage({ id: "actions.export" }),
- onClick: onExport,
- isDisplayed: showWhenSelected,
- },
- {
- text: intl.formatMessage({ id: "actions.export_all" }),
- onClick: onExportAll,
- },
- ];
+ const { filterHook, view, alterQuery, extraOperations = [] } = props;
- function addKeybinds(
- result: GQL.FindStudiosQueryResult,
- filter: ListFilterModel
- ) {
- Mousetrap.bind("p r", () => {
- viewRandom(result, filter);
+ // States
+ const {
+ showSidebar,
+ setShowSidebar,
+ sectionOpen,
+ setSectionOpen,
+ loading: sidebarStateLoading,
+ } = useSidebarState(view);
+
+ const { filterState, queryResult, modalState, listSelect, showEditFilter } =
+ useFilteredItemList({
+ filterStateProps: {
+ filterMode: GQL.FilterMode.Studios,
+ view,
+ useURL: alterQuery,
+ },
+ queryResultProps: {
+ useResult: useFindStudios,
+ getCount: (r) => r.data?.findStudios.count ?? 0,
+ getItems: (r) => r.data?.findStudios.studios ?? [],
+ filterHook,
+ },
+ });
+
+ const { filter, setFilter } = filterState;
+
+ const { effectiveFilter, result, cachedResult, items, totalCount } =
+ queryResult;
+
+ const {
+ selectedIds,
+ selectedItems,
+ onSelectChange,
+ onSelectAll,
+ onSelectNone,
+ onInvertSelection,
+ hasSelection,
+ } = listSelect;
+
+ const { modal, showModal, closeModal } = modalState;
+
+ // Utility hooks
+ const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
+ filter,
+ setFilter,
+ });
+
+ useAddKeybinds(filter, totalCount);
+ useFilteredSidebarKeybinds({
+ showSidebar,
+ setShowSidebar,
+ });
+
+ useEffect(() => {
+ Mousetrap.bind("e", () => {
+ if (hasSelection) {
+ onEdit?.();
+ }
+ });
+
+ Mousetrap.bind("d d", () => {
+ if (hasSelection) {
+ onDelete?.();
+ }
});
return () => {
- Mousetrap.unbind("p r");
+ Mousetrap.unbind("e");
+ Mousetrap.unbind("d d");
};
- }
+ });
- async function viewRandom(
- result: GQL.FindStudiosQueryResult,
- filter: ListFilterModel
- ) {
- // query for a random studio
- if (result.data?.findStudios) {
- const { count } = result.data.findStudios;
+ const onCloseEditDelete = useCloseEditDelete({
+ closeModal,
+ onSelectNone,
+ result,
+ });
- const index = Math.floor(Math.random() * count);
- const filterCopy = cloneDeep(filter);
- filterCopy.itemsPerPage = 1;
- filterCopy.currentPage = index + 1;
- const singleResult = await queryFindStudios(filterCopy);
- if (singleResult.data.findStudios.studios.length === 1) {
- const { id } = singleResult.data.findStudios.studios[0];
- // navigate to the studio page
- history.push(`/studios/${id}`);
- }
- }
- }
+ const viewRandom = useViewRandom(filter, totalCount);
- async function onExport() {
- setIsExportAll(false);
- setIsExportDialogOpen(true);
- }
-
- async function onExportAll() {
- setIsExportAll(true);
- setIsExportDialogOpen(true);
- }
-
- function renderContent(
- result: GQL.FindStudiosQueryResult,
- filter: ListFilterModel,
- selectedIds: Set,
- onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
- ) {
- function maybeRenderExportDialog() {
- if (isExportDialogOpen) {
- return (
- setIsExportDialogOpen(false)}
- />
- );
- }
- }
-
- function renderStudios() {
- if (!result.data?.findStudios) return;
-
- if (filter.displayMode === DisplayMode.Grid) {
- return (
-
- );
- }
- if (filter.displayMode === DisplayMode.List) {
- return TODO
;
- }
- if (filter.displayMode === DisplayMode.Wall) {
- return TODO
;
- }
- if (filter.displayMode === DisplayMode.Tagger) {
- return ;
- }
- }
-
- return (
- <>
- {maybeRenderExportDialog()}
- {renderStudios()}
- >
+ function onExport(all: boolean) {
+ showModal(
+ closeModal()}
+ />
);
}
- function renderEditDialog(
- selectedStudios: GQL.SlimStudioDataFragment[],
- onClose: (applied: boolean) => void
- ) {
- return ;
+ function onEdit() {
+ showModal(
+
+ );
}
- function renderDeleteDialog(
- selectedStudios: GQL.SlimStudioDataFragment[],
- onClose: (confirmed: boolean) => void
- ) {
- return (
+ function onDelete() {
+ showModal(
= PatchComponent(
);
}
+ const convertedExtraOperations = extraOperations.map((op) => ({
+ text: op.text,
+ onClick: () => op.onClick(result, filter, selectedIds),
+ isDisplayed: () => op.isDisplayed?.(result, filter, selectedIds) ?? true,
+ }));
+
+ const otherOperations = [
+ ...convertedExtraOperations,
+ {
+ text: intl.formatMessage({ id: "actions.select_all" }),
+ onClick: () => onSelectAll(),
+ isDisplayed: () => totalCount > 0,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.select_none" }),
+ onClick: () => onSelectNone(),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.invert_selection" }),
+ onClick: () => onInvertSelection(),
+ isDisplayed: () => totalCount > 0,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.view_random" }),
+ onClick: viewRandom,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.export" }),
+ onClick: () => onExport(false),
+ isDisplayed: () => hasSelection,
+ },
+ {
+ text: intl.formatMessage({ id: "actions.export_all" }),
+ onClick: () => onExport(true),
+ },
+ ];
+
+ // render
+ if (sidebarStateLoading) return null;
+
+ const operations = (
+
+ );
+
return (
-
-
-
+ {modal}
+
+
+
+ setShowSidebar(false)}>
+ setShowSidebar(false)}
+ count={cachedResult.loading ? undefined : totalCount}
+ focus={searchFocus}
+ />
+
+ setShowSidebar(!showSidebar)}
+ >
+
+
+ showEditFilter(c.criterionOption.type)}
+ onRemoveCriterion={removeCriterion}
+ onRemoveAll={clearAllCriteria}
+ />
+
+
+
setFilter(filter.changePage(page))}
+ />
+
+
+
+
+
+
+
+ {totalCount > filter.itemsPerPage && (
+
+ )}
+
+
+
+
);
}
);
diff --git a/ui/v2.5/src/components/Studios/Studios.tsx b/ui/v2.5/src/components/Studios/Studios.tsx
index 545de936f..956531fe0 100644
--- a/ui/v2.5/src/components/Studios/Studios.tsx
+++ b/ui/v2.5/src/components/Studios/Studios.tsx
@@ -4,11 +4,11 @@ import { Helmet } from "react-helmet";
import { useTitleProps } from "src/hooks/title";
import Studio from "./StudioDetails/Studio";
import StudioCreate from "./StudioDetails/StudioCreate";
-import { StudioList } from "./StudioList";
+import { FilteredStudioList } from "./StudioList";
import { View } from "../List/views";
const Studios: React.FC = () => {
- return ;
+ return ;
};
const StudioRoutes: React.FC = () => {
diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx
index 79f80708a..ac9444c5b 100755
--- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx
+++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx
@@ -240,7 +240,8 @@ const PerformerModal: React.FC = ({
height_cm: Number.parseFloat(performer.height ?? "") ?? undefined,
measurements: performer.measurements,
fake_tits: performer.fake_tits,
- career_length: performer.career_length,
+ career_start: performer.career_start,
+ career_end: performer.career_end,
tattoos: performer.tattoos,
piercings: performer.piercings,
urls: performer.urls,
@@ -326,7 +327,8 @@ const PerformerModal: React.FC = ({
{maybeRenderField("measurements", performer.measurements)}
{performer?.gender !== GQL.GenderEnum.Male &&
maybeRenderField("fake_tits", performer.fake_tits)}
- {maybeRenderField("career_length", performer.career_length)}
+ {maybeRenderField("career_start", performer.career_start?.toString())}
+ {maybeRenderField("career_end", performer.career_end?.toString())}
{maybeRenderField("tattoos", performer.tattoos, false)}
{maybeRenderField("piercings", performer.piercings, false)}
{maybeRenderField("weight", performer.weight, false)}
diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts
index d499062aa..d59a6d3d5 100644
--- a/ui/v2.5/src/components/Tagger/constants.ts
+++ b/ui/v2.5/src/components/Tagger/constants.ts
@@ -75,7 +75,8 @@ export const PERFORMER_FIELDS = [
"fake_tits",
"tattoos",
"piercings",
- "career_length",
+ "career_start",
+ "career_end",
"urls",
"details",
];
diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx
index 077300788..22c99b80e 100644
--- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx
+++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx
@@ -15,7 +15,7 @@ import { useToast } from "src/hooks/Toast";
import { useConfigurationContext } from "src/hooks/Config";
import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form";
-import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
+import { yupFormikValidate, yupRequiredStringArray } from "src/utils/yup";
import { addUpdateStashID, getStashIDs } from "src/utils/stashIds";
import { Tag, TagSelect } from "../TagSelect";
import { Icon } from "src/components/Shared/Icon";
@@ -56,7 +56,7 @@ export const TagEditPanel: React.FC = ({
const schema = yup.object({
name: yup.string().required(),
sort_name: yup.string().ensure(),
- aliases: yupUniqueAliases(intl, "name"),
+ aliases: yupRequiredStringArray(intl).defined(),
description: yup.string().ensure(),
parent_ids: yup.array(yup.string().required()).defined(),
child_ids: yup.array(yup.string().required()).defined(),
diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx
index bb95a7ea1..f5df9946b 100644
--- a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx
+++ b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx
@@ -1,7 +1,7 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useTagFilterHook } from "src/core/tags";
-import { GalleryList } from "src/components/Galleries/GalleryList";
+import { FilteredGalleryList } from "src/components/Galleries/GalleryList";
import { View } from "src/components/List/views";
interface ITagGalleriesPanel {
@@ -17,7 +17,7 @@ export const TagGalleriesPanel: React.FC = ({
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return (
- = ({ active, tag, showSubTagContent }) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
- return ;
+ return (
+
+ );
};
diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx
index 4891c0daf..a512ef5a3 100644
--- a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx
+++ b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx
@@ -1,7 +1,7 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useTagFilterHook } from "src/core/tags";
-import { PerformerList } from "src/components/Performers/PerformerList";
+import { FilteredPerformerList } from "src/components/Performers/PerformerList";
import { View } from "src/components/List/views";
interface ITagPerformersPanel {
@@ -17,7 +17,7 @@ export const TagPerformersPanel: React.FC = ({
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return (
- = ({
showSubTagContent,
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
- return ;
+ return ;
};
diff --git a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx
index 15b648af5..a66ce5789 100644
--- a/ui/v2.5/src/components/Tags/TagMergeDialog.tsx
+++ b/ui/v2.5/src/components/Tags/TagMergeDialog.tsx
@@ -1,13 +1,412 @@
import { Button, Form, Col, Row } from "react-bootstrap";
-import React, { useEffect, useState } from "react";
+import * as GQL from "src/core/generated-graphql";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Icon } from "../Shared/Icon";
import { ModalComponent } from "src/components/Shared/Modal";
import * as FormUtils from "src/utils/form";
-import { useTagsMerge } from "src/core/StashService";
-import { useIntl } from "react-intl";
+import { queryFindTagsByID, useTagsMerge } from "src/core/StashService";
+import { FormattedMessage, useIntl } from "react-intl";
import { useToast } from "src/hooks/Toast";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import { Tag, TagSelect } from "./TagSelect";
+import {
+ CustomFieldScrapeResults,
+ hasScrapedValues,
+ ObjectListScrapeResult,
+ ScrapeResult,
+} from "../Shared/ScrapeDialog/scrapeResult";
+import { sortStoredIdObjects } from "src/utils/data";
+import ImageUtils from "src/utils/image";
+import { uniq } from "lodash-es";
+import { LoadingIndicator } from "../Shared/LoadingIndicator";
+import {
+ ScrapedCustomFieldRows,
+ ScrapeDialogRow,
+ ScrapedImageRow,
+ ScrapedInputGroupRow,
+ ScrapedStringListRow,
+ ScrapedTextAreaRow,
+} from "../Shared/ScrapeDialog/ScrapeDialogRow";
+import { ScrapedTagsRow } from "../Shared/ScrapeDialog/ScrapedObjectsRow";
+import { StringListSelect } from "../Shared/Select";
+import { ScrapeDialog } from "../Shared/ScrapeDialog/ScrapeDialog";
+
+interface IStashIDsField {
+ values: GQL.StashId[];
+}
+
+const StashIDsField: React.FC = ({ values }) => {
+ return v.stash_id)} />;
+};
+
+interface ITagMergeDetailsProps {
+ sources: GQL.TagDataFragment[];
+ dest: GQL.TagDataFragment;
+ onClose: (values?: GQL.TagUpdateInput) => void;
+}
+
+const TagMergeDetails: React.FC = ({
+ sources,
+ dest,
+ onClose,
+}) => {
+ const intl = useIntl();
+
+ const [loading, setLoading] = useState(true);
+
+ const filterCandidates = useCallback(
+ (t: { stored_id: string }) =>
+ t.stored_id !== dest.id && sources.every((s) => s.id !== t.stored_id),
+ [dest.id, sources]
+ );
+
+ const [name, setName] = useState>(
+ new ScrapeResult(dest.name)
+ );
+ const [sortName, setSortName] = useState>(
+ new ScrapeResult(dest.sort_name)
+ );
+ const [aliases, setAliases] = useState>(
+ new ScrapeResult(dest.aliases)
+ );
+ const [description, setDescription] = useState>(
+ new ScrapeResult(dest.description)
+ );
+ const [parentTags, setParentTags] = useState<
+ ObjectListScrapeResult
+ >(
+ new ObjectListScrapeResult(
+ sortStoredIdObjects(
+ dest.parents.map(idToStoredID).filter(filterCandidates)
+ )
+ )
+ );
+ const [childTags, setChildTags] = useState<
+ ObjectListScrapeResult
+ >(
+ new ObjectListScrapeResult(
+ sortStoredIdObjects(
+ dest.children.map(idToStoredID).filter(filterCandidates)
+ )
+ )
+ );
+
+ const [stashIDs, setStashIDs] = useState(new ScrapeResult([]));
+
+ const [image, setImage] = useState>(
+ new ScrapeResult(dest.image_path)
+ );
+
+ const [customFields, setCustomFields] = useState(
+ new Map()
+ );
+
+ function idToStoredID(o: { id: string; name: string }) {
+ return {
+ stored_id: o.id,
+ name: o.name,
+ };
+ }
+
+ // calculate the values for everything
+ // uses the first set value for single value fields, and combines all
+ useEffect(() => {
+ async function loadImages() {
+ const src = sources.find((s) => s.image_path);
+ if (!dest.image_path || !src) return;
+
+ setLoading(true);
+
+ const destData = await ImageUtils.imageToDataURL(dest.image_path);
+ const srcData = await ImageUtils.imageToDataURL(src.image_path!);
+
+ // keep destination image by default
+ const useNewValue = false;
+ setImage(new ScrapeResult(destData, srcData, useNewValue));
+
+ setLoading(false);
+ }
+
+ // append dest to all so that if dest has stash_ids with the same
+ // endpoint, then it will be excluded first
+ const all = sources.concat(dest);
+
+ setName(
+ new ScrapeResult(dest.name, sources.find((s) => s.name)?.name, !dest.name)
+ );
+ setSortName(
+ new ScrapeResult(
+ dest.sort_name,
+ sources.find((s) => s.sort_name)?.sort_name,
+ !dest.sort_name
+ )
+ );
+
+ setDescription(
+ new ScrapeResult(
+ dest.description,
+ sources.find((s) => s.description)?.description,
+ !dest.description
+ )
+ );
+
+ // default alias list should be the existing aliases, plus the names of all sources,
+ // plus all source aliases, deduplicated
+ const allAliases = uniq(
+ dest.aliases.concat(
+ sources.map((s) => s.name),
+ sources.flatMap((s) => s.aliases)
+ )
+ );
+ setAliases(new ScrapeResult(dest.aliases, allAliases, !!allAliases.length));
+
+ // default parent/child tags should be the existing tags, plus all source parent/child tags, deduplicated
+ const allParentTags = uniq(all.flatMap((s) => s.parents))
+ .map(idToStoredID)
+ .filter(filterCandidates); // exclude self and sources
+
+ setParentTags(
+ new ObjectListScrapeResult(
+ sortStoredIdObjects(dest.parents.map(idToStoredID)),
+ sortStoredIdObjects(allParentTags),
+ !!allParentTags.length
+ )
+ );
+
+ const allChildTags = uniq(all.flatMap((s) => s.children))
+ .map(idToStoredID)
+ .filter(filterCandidates); // exclude self and sources
+
+ setChildTags(
+ new ObjectListScrapeResult(
+ sortStoredIdObjects(
+ dest.children.map(idToStoredID).filter(filterCandidates)
+ ),
+ sortStoredIdObjects(allChildTags),
+ !!allChildTags.length
+ )
+ );
+
+ setStashIDs(
+ new ScrapeResult(
+ dest.stash_ids,
+ all
+ .map((s) => s.stash_ids)
+ .flat()
+ .filter((s, index, a) => {
+ // remove entries with duplicate endpoints
+ return index === a.findIndex((ss) => ss.endpoint === s.endpoint);
+ })
+ )
+ );
+
+ setImage(
+ new ScrapeResult(
+ dest.image_path,
+ sources.find((s) => s.image_path)?.image_path,
+ !dest.image_path
+ )
+ );
+
+ const customFieldNames = new Set(Object.keys(dest.custom_fields));
+
+ for (const s of sources) {
+ for (const n of Object.keys(s.custom_fields)) {
+ customFieldNames.add(n);
+ }
+ }
+
+ setCustomFields(
+ new Map(
+ Array.from(customFieldNames)
+ .sort()
+ .map((field) => {
+ return [
+ field,
+ new ScrapeResult(
+ dest.custom_fields?.[field],
+ sources.find((s) => s.custom_fields?.[field])?.custom_fields?.[
+ field
+ ],
+ dest.custom_fields?.[field] === undefined
+ ),
+ ];
+ })
+ )
+ );
+
+ loadImages();
+ }, [sources, dest, filterCandidates]);
+
+ const hasCustomFieldValues = useMemo(() => {
+ return hasScrapedValues(Array.from(customFields.values()));
+ }, [customFields]);
+
+ // ensure this is updated if fields are changed
+ const hasValues = useMemo(() => {
+ return (
+ hasCustomFieldValues ||
+ hasScrapedValues([
+ name,
+ sortName,
+ aliases,
+ description,
+ parentTags,
+ childTags,
+ stashIDs,
+ image,
+ ])
+ );
+ }, [
+ name,
+ sortName,
+ aliases,
+ description,
+ parentTags,
+ childTags,
+ stashIDs,
+ image,
+ hasCustomFieldValues,
+ ]);
+
+ function renderScrapeRows() {
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!hasValues) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+ setName(value)}
+ />
+ setSortName(value)}
+ />
+ setAliases(value)}
+ />
+ setParentTags(value)}
+ />
+ setChildTags(value)}
+ />
+ setDescription(value)}
+ />
+
+ }
+ newField={}
+ onChange={(value) => setStashIDs(value)}
+ />
+ setImage(value)}
+ />
+ {hasCustomFieldValues && (
+ setCustomFields(newCustomFields)}
+ />
+ )}
+ >
+ );
+ }
+
+ function createValues(): GQL.TagUpdateInput {
+ // only set the cover image if it's different from the existing cover image
+ const coverImage = image.useNewValue ? image.getNewValue() : undefined;
+
+ return {
+ id: dest.id,
+ name: name.getNewValue(),
+ sort_name: sortName.getNewValue(),
+ aliases: aliases
+ .getNewValue()
+ ?.map((s) => s.trim())
+ .filter((s) => s.length > 0),
+ parent_ids: parentTags.getNewValue()?.map((t) => t.stored_id!),
+ child_ids: childTags.getNewValue()?.map((t) => t.stored_id!),
+ description: description.getNewValue(),
+ stash_ids: stashIDs.getNewValue(),
+ image: coverImage,
+ custom_fields: {
+ partial: Object.fromEntries(
+ Array.from(customFields.entries()).flatMap(([field, v]) =>
+ v.useNewValue ? [[field, v.getNewValue()]] : []
+ )
+ ),
+ },
+ };
+ }
+
+ const dialogTitle = intl.formatMessage({
+ id: "actions.merge",
+ });
+
+ const destinationLabel = !hasValues
+ ? ""
+ : intl.formatMessage({ id: "dialogs.merge.destination" });
+ const sourceLabel = !hasValues
+ ? ""
+ : intl.formatMessage({ id: "dialogs.merge.source" });
+
+ return (
+ {
+ if (!apply) {
+ onClose();
+ } else {
+ onClose(createValues());
+ }
+ }}
+ >
+ {renderScrapeRows()}
+
+ );
+};
interface ITagMergeModalProps {
show: boolean;
@@ -23,6 +422,11 @@ export const TagMergeModal: React.FC = ({
const [src, setSrc] = useState([]);
const [dest, setDest] = useState(null);
+ const [loadedSources, setLoadedSources] = useState([]);
+ const [loadedDest, setLoadedDest] = useState();
+
+ const [secondStep, setSecondStep] = useState(false);
+
const [running, setRunning] = useState(false);
const [mergeTags] = useTagsMerge();
@@ -41,7 +445,23 @@ export const TagMergeModal: React.FC = ({
}
}, [tags]);
- async function onMerge() {
+ async function loadTags() {
+ try {
+ const tagIDs = src.map((s) => s.id);
+ tagIDs.push(dest!.id);
+ const query = await queryFindTagsByID(tagIDs);
+ const { tags: loadedTags } = query.data.findTags;
+
+ setLoadedDest(loadedTags.find((s) => s.id === dest!.id));
+ setLoadedSources(loadedTags.filter((s) => s.id !== dest!.id));
+ setSecondStep(true);
+ } catch (e) {
+ Toast.error(e);
+ return;
+ }
+ }
+
+ async function onMerge(values: GQL.TagUpdateInput) {
if (!dest) return;
const source = src.map((s) => s.id);
@@ -53,6 +473,7 @@ export const TagMergeModal: React.FC = ({
variables: {
source,
destination,
+ values,
},
});
if (result.data?.tagsMerge) {
@@ -78,6 +499,23 @@ export const TagMergeModal: React.FC = ({
}
}
+ if (secondStep && dest) {
+ return (
+ {
+ setSecondStep(false);
+ if (values) {
+ onMerge(values);
+ } else {
+ onClose();
+ }
+ }}
+ />
+ );
+ }
+
return (
= ({
icon={faSignInAlt}
accept={{
text: intl.formatMessage({ id: "actions.merge" }),
- onClick: () => onMerge(),
+ onClick: () => loadTags(),
}}
disabled={!canMerge()}
cancel={{
diff --git a/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx
new file mode 100644
index 000000000..164774446
--- /dev/null
+++ b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx
@@ -0,0 +1,67 @@
+import React, { useState } from "react";
+import { Button } from "react-bootstrap";
+import { FormattedMessage, useIntl } from "react-intl";
+import { faBug } from "@fortawesome/free-solid-svg-icons";
+import { ModalComponent } from "src/components/Shared/Modal";
+import { useTroubleshootingMode } from "./useTroubleshootingMode";
+
+const DIALOG_ITEMS = [
+ "config.ui.troubleshooting_mode.dialog_item_plugins",
+ "config.ui.troubleshooting_mode.dialog_item_css",
+ "config.ui.troubleshooting_mode.dialog_item_js",
+ "config.ui.troubleshooting_mode.dialog_item_locales",
+] as const;
+
+export const TroubleshootingModeButton: React.FC = () => {
+ const intl = useIntl();
+ const [showDialog, setShowDialog] = useState(false);
+ const { enable, isLoading } = useTroubleshootingMode();
+
+ return (
+ <>
+
+
+
+
+ setShowDialog(false)}
+ header={intl.formatMessage({
+ id: "config.ui.troubleshooting_mode.dialog_title",
+ })}
+ icon={faBug}
+ accept={{
+ text: intl.formatMessage({
+ id: "config.ui.troubleshooting_mode.enable",
+ }),
+ variant: "primary",
+ onClick: enable,
+ }}
+ cancel={{
+ onClick: () => setShowDialog(false),
+ variant: "secondary",
+ }}
+ isRunning={isLoading}
+ >
+
+
+
+
+ {DIALOG_ITEMS.map((id) => (
+ -
+
+
+ ))}
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx
new file mode 100644
index 000000000..bf2b38f8a
--- /dev/null
+++ b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import { Button } from "react-bootstrap";
+import { FormattedMessage } from "react-intl";
+import { faBug } from "@fortawesome/free-solid-svg-icons";
+import { Icon } from "src/components/Shared/Icon";
+import { useTroubleshootingMode } from "./useTroubleshootingMode";
+
+export const TroubleshootingModeOverlay: React.FC = () => {
+ const { isActive, isLoading, disable } = useTroubleshootingMode();
+
+ if (!isActive) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts b/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts
new file mode 100644
index 000000000..63b4edd4f
--- /dev/null
+++ b/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts
@@ -0,0 +1,83 @@
+import { useState, useRef, useEffect } from "react";
+import {
+ useConfigureInterface,
+ useConfigureGeneral,
+ useConfiguration,
+} from "src/core/StashService";
+
+const ORIGINAL_LOG_LEVEL_KEY = "troubleshootingMode_originalLogLevel";
+
+export function useTroubleshootingMode() {
+ const [isLoading, setIsLoading] = useState(false);
+ const isMounted = useRef(true);
+
+ const { data: config } = useConfiguration();
+ const [configureInterface] = useConfigureInterface();
+ const [configureGeneral] = useConfigureGeneral();
+
+ const isActive =
+ config?.configuration?.interface?.disableCustomizations ?? false;
+ const currentLogLevel = config?.configuration?.general?.logLevel || "Info";
+
+ useEffect(() => {
+ return () => {
+ isMounted.current = false;
+ };
+ }, []);
+
+ async function enable() {
+ setIsLoading(true);
+ try {
+ // Store original log level for restoration later
+ localStorage.setItem(ORIGINAL_LOG_LEVEL_KEY, currentLogLevel);
+
+ // Enable troubleshooting mode and set log level to Debug
+ await Promise.all([
+ configureInterface({
+ variables: { input: { disableCustomizations: true } },
+ }),
+ configureGeneral({
+ variables: { input: { logLevel: "Debug" } },
+ }),
+ ]);
+
+ window.location.reload();
+ } catch (e) {
+ if (isMounted.current) {
+ setIsLoading(false);
+ }
+ throw e;
+ }
+ }
+
+ async function disable() {
+ setIsLoading(true);
+ try {
+ // Restore original log level
+ const originalLogLevel =
+ localStorage.getItem(ORIGINAL_LOG_LEVEL_KEY) || "Info";
+
+ // Disable troubleshooting mode and restore log level
+ await Promise.all([
+ configureInterface({
+ variables: { input: { disableCustomizations: false } },
+ }),
+ configureGeneral({
+ variables: { input: { logLevel: originalLogLevel } },
+ }),
+ ]);
+
+ // Clean up localStorage
+ localStorage.removeItem(ORIGINAL_LOG_LEVEL_KEY);
+
+ window.location.reload();
+ } catch (e) {
+ if (isMounted.current) {
+ setIsLoading(false);
+ }
+ throw e;
+ }
+ }
+
+ return { isActive, isLoading, enable, disable };
+}
diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts
index 6aaf17125..58b1aae42 100644
--- a/ui/v2.5/src/core/StashService.ts
+++ b/ui/v2.5/src/core/StashService.ts
@@ -472,6 +472,14 @@ export const queryFindTagsForList = (filter: ListFilterModel) =>
},
});
+export const queryFindTagsByID = (tagIDs: string[]) =>
+ client.query({
+ query: GQL.FindTagsDocument,
+ variables: {
+ ids: tagIDs,
+ },
+ });
+
export const queryFindTagsByIDForSelect = (tagIDs: string[]) =>
client.query({
query: GQL.FindTagsForSelectDocument,
diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts
index 9712c9824..016e9e13f 100644
--- a/ui/v2.5/src/core/performers.ts
+++ b/ui/v2.5/src/core/performers.ts
@@ -104,7 +104,10 @@ export const scrapedPerformerToCreateInput = (
height_cm: toCreate.height ? Number(toCreate.height) : undefined,
measurements: toCreate.measurements,
fake_tits: toCreate.fake_tits,
- career_length: toCreate.career_length,
+ career_start: toCreate.career_start
+ ? Number(toCreate.career_start)
+ : undefined,
+ career_end: toCreate.career_end ? Number(toCreate.career_end) : undefined,
tattoos: toCreate.tattoos,
piercings: toCreate.piercings,
alias_list: aliases,
diff --git a/ui/v2.5/src/core/recommendations.ts b/ui/v2.5/src/core/recommendations.ts
index b0a1232e4..7c55fed9d 100644
--- a/ui/v2.5/src/core/recommendations.ts
+++ b/ui/v2.5/src/core/recommendations.ts
@@ -16,7 +16,7 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) {
return {
dots: !isTouch,
arrows: !isTouch,
- infinite: !isTouch,
+ infinite: !isTouch && cardCount > 5,
speed: 300,
variableWidth: true,
swipeToSlide: true,
@@ -26,6 +26,7 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) {
{
breakpoint: 1909,
settings: {
+ infinite: !isTouch && cardCount > 4,
slidesToShow: cardCount! > 4 ? 4 : cardCount,
slidesToScroll: determineSlidesToScroll(cardCount!, 4, isTouch),
},
@@ -33,6 +34,7 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) {
{
breakpoint: 1542,
settings: {
+ infinite: !isTouch && cardCount > 3,
slidesToShow: cardCount! > 3 ? 3 : cardCount,
slidesToScroll: determineSlidesToScroll(cardCount!, 3, isTouch),
},
@@ -40,6 +42,7 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) {
{
breakpoint: 1170,
settings: {
+ infinite: !isTouch && cardCount > 2,
slidesToShow: cardCount! > 2 ? 2 : cardCount,
slidesToScroll: determineSlidesToScroll(cardCount!, 2, isTouch),
},
@@ -47,9 +50,10 @@ export function getSlickSliderSettings(cardCount: number, isTouch: boolean) {
{
breakpoint: 801,
settings: {
+ infinite: !isTouch && cardCount > 1,
slidesToShow: 1,
slidesToScroll: 1,
- dots: false,
+ dots: cardCount < 6,
},
},
],
diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md
index 4b1cbb813..c3ef00971 100644
--- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md
+++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md
@@ -1,16 +1,16 @@
# Auto Tag
-Auto Tag automatically assigns Performers, Studios, and Tags to your media based on their names found in file paths or filenames. This task works for scenes, images, and galleries.
+Auto tag automatically assigns Performers, Studios, and Tags to your media based on their names found in file paths or filenames. This task works for scenes, images, and galleries.
This task is part of the advanced settings mode.
## Rules
-> **Important:** Auto Tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags.
+> **⚠️ Important:** Auto tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags.
- Multi-word names are matched when words appear in order and are separated by any of these characters: `.`, `-`, `_`, or whitespace. These separators are treated as word boundaries.
- Matching is case-insensitive but requires complete words within word boundaries. Partial words or misspelled words will not match.
- - Auto Tag does not match performer aliases. Aliases will not be considered during matching.
+ - Auto tag does not match performer aliases. Aliases will not be considered during matching.
### Examples (performer "Jane Doe")
@@ -35,14 +35,16 @@ This task is part of the advanced settings mode.
### Organized flag
-Scenes, images, and galleries that have the Organized flag added to them will not be modified by Auto Tag. You can also use Organized flag status as a filter.
+Scenes, images, and galleries that have the Organized flag added to them will not be modified by Auto tag. You can also use Organized flag status as a filter.
-### Ignore Auto Tag flag
+Studios also support the Organized flag, however it is purely informational. It serves as a front-end indicator for the user to mark that a studio's collection is complete and does not affect Auto tag behavior. The Ignore Auto tag flag should be used to exclude a studio from Auto tag.
-Performers or Tags that have Ignore Auto Tag flag added to them will be skipped by the Auto Tag task.
+### Ignore Auto tag flag
+
+Performers or Tags that have Ignore Auto tag flag added to them will be skipped by the Auto tag task.
## Running task
-- **Auto Tag:** You can run the Auto Tag task on your entire library from the Tasks page.
-- **Selective Auto Tag:** You can run the Auto Tag task on specific directories from the Tasks page.
-- **Individual pages:** You can run Auto Tag tasks for specific Performers, Studios, and Tags from their respective pages.
+- **Auto tag:** You can run the Auto tag task on your entire library from the Tasks page.
+- **Selective auto tag:** You can run the Auto tag task on specific directories from the Tasks page.
+- **Individual pages:** You can run Auto tag tasks for specific Performers, Studios, and Tags from their respective pages.
diff --git a/ui/v2.5/src/docs/en/Manual/Captions.md b/ui/v2.5/src/docs/en/Manual/Captions.md
index df2bee8bc..a575f915b 100644
--- a/ui/v2.5/src/docs/en/Manual/Captions.md
+++ b/ui/v2.5/src/docs/en/Manual/Captions.md
@@ -15,4 +15,4 @@ Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/
Scenes with captions can be filtered with the `captions` criterion.
-**Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective Scan task for it to show up.
+> **⚠️ Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective scan task for it to show up.
diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md
index 76464facf..2d08f9750 100644
--- a/ui/v2.5/src/docs/en/Manual/Configuration.md
+++ b/ui/v2.5/src/docs/en/Manual/Configuration.md
@@ -31,7 +31,7 @@ Some examples:
- `"^/stash/videos/exclude/"` will exclude all directories that match `/stash/videos/exclude/` pattern.
- `"\\\\stash\\network\\share\\excl\\"` will exclude specific Windows network path `\\stash\network\share\excl\`.
-> **Note:** If a directory is excluded for images and videos, then the directory will be excluded from scans completely.
+> **⚠️ Note:** If a directory is excluded for images and videos, then the directory will be excluded from scans completely.
_There is a useful [regex101](https://regex101.com/) site that can help test and experiment with regexps._
@@ -87,7 +87,37 @@ This setting can be used to increase/decrease overall CPU utilisation in two sce
1. High performance 4+ core cpus.
2. Media files stored on remote/cloud filesystem.
-Note: If this is set too high it will decrease overall performance and causes failures (out of memory).
+> **⚠️ Note:** If this is set too high it will decrease overall performance and causes failures (out of memory).
+
+## Sprite generation
+
+### Sprite size
+
+Fixed size of a generated sprite, being the longest dimension in pixels.
+Setting this to `0` will fallback to the default of `160`.
+Althought it is possible to set this value to anything bigger than `0` it is recommended to set it to `160` at least.
+
+### Use custom sprite generation
+
+If this setting is disabled, the settings below will be ignored and the default sprite generation settings are used.
+
+### Sprite interval
+
+This represents the time in seconds between each sprite to be generated. This value will be adjusted if necessary to fit within the bounds of the `Minimum Sprites` and `Maximum Sprites` settings.
+
+Setting this to `0` means that the sprite interval will be calculated based on the value of the `Minimum Sprites` field.
+
+### Minimum sprites
+
+The minimal number of distinct sprites that will be generated for a scene. `Sprite interval` will be adjusted if necessary.
+Setting this to `0` will fallback to the default of `10`
+
+### Maximum sprites
+
+The maximum number of distinct sprites that will be generated for a scene. `Sprite interval` will be adjusted if necessary.
+Setting this to `0` indicates there is no maximum.
+
+> **⚠️ Note:** The number of generated sprites is adjusted upwards to the next perfect square to ensure the sprite image is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns). This means that if you set a minimum of 10 sprites, 16 will actually be generated, and if you set a maximum of 15 sprites, 16 will actually be generated.
## Hardware accelerated live transcoding
@@ -117,7 +147,7 @@ Some scrapers require a Chrome instance to function correctly. If left empty, st
`Chrome CDP path` can be set to a path to the chrome executable, or an http(s) address to remote chrome instance (for example: `http://localhost:9222/json/version`).
-> **Important**: As of Chrome 136 you need to specify `--user-data-dir` alongside `--remote-debugging-port`. Read more on their [official post](https://developer.chrome.com/blog/remote-debugging-port).
+> **⚠️ Important:** As of Chrome 136 you need to specify `--user-data-dir` alongside `--remote-debugging-port`. Read more on their [official post](https://developer.chrome.com/blog/remote-debugging-port).
## Authentication
diff --git a/ui/v2.5/src/docs/en/Manual/Deduplication.md b/ui/v2.5/src/docs/en/Manual/Deduplication.md
index 24c0fb391..d842fcc68 100644
--- a/ui/v2.5/src/docs/en/Manual/Deduplication.md
+++ b/ui/v2.5/src/docs/en/Manual/Deduplication.md
@@ -2,8 +2,10 @@
[The dupe checker](/sceneDuplicateChecker) searches your collection for scenes that are perceptually similar. This means that the files don't need to be identical, and will be identified even with different bitrates, resolutions, and intros/outros.
-To achieve this stash needs to generate what's called a phash, or perceptual hash. Similar to sprite generation stash will generate a set of 25 images from fixed points in the scene. These images will be stitched together, and then hashed using the phash algorithm. The phash can then be used to find scenes that are the same or similar to others in the database. Phash generation can be run during scan, or as a separate task. Note that generation can take a while due to the work involved with extracting screenshots.
+To achieve this stash needs to generate what's called a phash, or perceptual hash. Similar to sprite generation stash will generate a set of 25 images from fixed points in the scene. These images will be stitched together, and then hashed using the phash algorithm. The phash can then be used to find scenes that are the same or similar to others in the database. Phash generation can be run during scan, or as a separate task.
+
+> **⚠️ Note:** Generation can take a while due to the work involved with extracting screenshots.
The dupe checker can be run with four different levels of accuracy. `Exact` looks for scenes that have exactly the same phash. This is a fast and accurate operation that should not yield any false positives except in very rare cases. The other accuracy levels look for duplicate files within a set distance of each other. This means the scenes don't have exactly the same phash, but are very similar. `High` and `Medium` should still yield very good results with few or no false positives. `Low` is likely to produce some false positives, but might still be useful for finding dupes.
-Note that to generate a phash stash requires an uncorrupted file. If any errors are encountered during sprite generation the phash will not be generated. This is to prevent false positives.
+> **⚠️ Note:** To generate a pHash Stash requires an uncorrupted file. If any errors are encountered during sprite generation the pHash will not be generated. This is to prevent false positives.
\ No newline at end of file
diff --git a/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md b/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md
index 1fc217ffc..9d54010e6 100644
--- a/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md
+++ b/ui/v2.5/src/docs/en/Manual/EmbeddedPlugins.md
@@ -10,7 +10,9 @@ Stash currently supports Javascript embedded plugin tasks using [goja](https://g
### Plugin input
-The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. Note that the `server_connection` field should not be necessary in most embedded plugins.
+The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page.
+
+> **⚠️ Note:** `server_connection` field should not be necessary in most embedded plugins.
### Plugin output
diff --git a/ui/v2.5/src/docs/en/Manual/Identify.md b/ui/v2.5/src/docs/en/Manual/Identify.md
index 724a392a3..9407ac9d9 100644
--- a/ui/v2.5/src/docs/en/Manual/Identify.md
+++ b/ui/v2.5/src/docs/en/Manual/Identify.md
@@ -20,7 +20,7 @@ The following options can be configured:
| Option | Description |
|--------|-------------|
-| Include male performers | If false, male performers will not be created or set on scenes. |
+| Performer genders | Filter which performer genders are included during identification. If no genders are selected, all performers are included regardless of gender. |
| Set cover images | If false, scene cover images will not be modified. |
| Set organized flag | If true, the organized flag is set to true when a scene is organized. |
| Skip matches that have more than one result | If this is not enabled and more than one result is returned, one will be randomly chosen to match |
diff --git a/ui/v2.5/src/docs/en/Manual/Images.md b/ui/v2.5/src/docs/en/Manual/Images.md
index ede9b3457..5be7beba5 100644
--- a/ui/v2.5/src/docs/en/Manual/Images.md
+++ b/ui/v2.5/src/docs/en/Manual/Images.md
@@ -11,7 +11,7 @@ You can add images to every gallery manually in the gallery detail page. Deletin
For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance.
-> **:warning: Note:** AVIF files in ZIP archives are currently unsupported.
+> **⚠️ Note:** AVIF files in ZIP archives are currently unsupported.
If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected.
@@ -21,11 +21,11 @@ You can also manually select any image from a gallery as its cover. On the galle
Images can also be clips/gifs. These are meant to be short video loops. Right now they are not possible in zipfiles. To declare video files to be images, there are two ways:
-1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan Video Extensions as Image Clip** option in the library section of your settings.
-2. Make sure none of the file endings used by your clips/gifs are present in the **Video Extensions** and add them to the **Image Extensions** in the library section of your settings.
+1. Deactivate video scanning for all libraries that contain clips/gifs, but keep image scanning active. Set the **Scan video extensions as image clips** option in the library section of your settings.
+2. Make sure none of the file endings used by your clips/gifs are present in the **Video extensions** and add them to the **Image extensions** in the library section of your settings.
A clip/gif will be a stillframe in the wall and grid view by default. To view the loop, you can go into the Lightbox Carousel (e.g. by clicking on an image in the wall view) or the image detail page.
If you want the loop to be used as a preview on the wall and grid view, you will have to generate them.
-You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image Clip Previews** and clicking generate. This takes a while, as the files are transcoded.
+You can do this as you scan for the new clip file by activating **Generate previews for image clips** on the scan settings, or do it after by going to the **Generated Content** section in the task section of your settings, activating **Image clip previews** and clicking generate. This takes a while, as the files are transcoded.
diff --git a/ui/v2.5/src/docs/en/Manual/Interactive.md b/ui/v2.5/src/docs/en/Manual/Interactive.md
index 831109aab..ab12381dc 100644
--- a/ui/v2.5/src/docs/en/Manual/Interactive.md
+++ b/ui/v2.5/src/docs/en/Manual/Interactive.md
@@ -1,8 +1,8 @@
# Interactivity
-Stash currently supports syncing with Handy devices, using funscript files.
+Stash currently supports syncing with The Handy devices, using funscript files.
-In order for stash to connect to your Handy device, the Handy Connection Key must be entered in Settings -> Interface.
+In order for stash to connect to your Handy device, the Handy connection key must be entered in Settings -> Interface.
Funscript files must be in the same directory as the matching video file and must have the same base name. For example, a funscript file for `video.mp4` must be named `video.funscript`. A scan must be run to update scenes with matching funscript files.
diff --git a/ui/v2.5/src/docs/en/Manual/Interface.md b/ui/v2.5/src/docs/en/Manual/Interface.md
index cf5911405..951fb3323 100644
--- a/ui/v2.5/src/docs/en/Manual/Interface.md
+++ b/ui/v2.5/src/docs/en/Manual/Interface.md
@@ -4,20 +4,20 @@
Setting the language affects the formatting of numbers and dates.
-## SFW Content Mode
+## SFW content mode
-SFW Content Mode is used to indicate that the content being managed is _not_ adult content.
+SFW content mode is used to indicate that the content being managed is _not_ adult content.
-When SFW Content Mode is enabled, the following changes are made to the UI:
+When SFW content mode is enabled, the following changes are made to the UI:
- default performer images are changed to less adult-oriented images
- certain adult-specific metadata fields are hidden (e.g. performer genital fields)
- `O`-Counter is replaced with `Like`-counter
-## Scene/Marker Wall Preview Type
+## Scene/Marker Wall Preview type
The Scene Wall and Marker pages display scene preview videos (mp4) by default. This can be changed to animated image (webp) or static image.
-> **⚠️ Note:** scene/marker preview videos must be generated to see them in the applicable wall page if Video preview type is selected. Likewise, if Animated Image is selected, then Image Previews must be generated.
+> **⚠️ Note:** scene/marker preview videos must be generated to see them in the applicable wall page if Video preview type is selected. Likewise, if Animated image is selected, then Image Previews must be generated.
## Show Studios as text
@@ -33,25 +33,25 @@ The maximum loop duration option allows looping of shorter videos. Set this valu
The "Track Activity" option allows tracking of scene play count and duration, and sets the resume point when a scene video is not finished.
-The "Minimum Play Percent" gives the minimum proportion of a video that must be played before the play count of the scene is incremented.
+The "Minimum play percent" gives the minimum proportion of a video that must be played before the play count of the scene is incremented.
By default, when a scene has a resume point, the scene player will automatically seek to this point when the scene is played. Setting "Always start video from beginning" to true disables this behaviour.
## Custom CSS
-The stash UI can be customised using custom CSS. See [here](https://docs.stashapp.cc/themes/custom-css-snippets/) for a community-curated set of CSS snippets to customise your UI.
+The stash UI can be customised using custom CSS. See [here](https://discourse.stashapp.cc/t/custom-css-snippets/4043) for a community-curated set of CSS snippets to customise your UI.
-There is also a [collection of community-created themes](https://docs.stashapp.cc/themes/list/#browse-themes) available.
+There is also a [collection of community-created themes](https://discourse.stashapp.cc/tags/c/plugins/18/all/theme) available.
-## Custom Javascript
+## Custom JavaScript
-Stash supports the injection of custom javascript to assist with theming or adding additional functionality. Be aware that bad Javascript could break the UI or worse.
+Stash supports the injection of custom JavaScript to assist with theming or adding additional functionality. Be aware that bad JavaScript could break the UI or worse.
## Custom Locales
The localisation strings can be customised. The master list of default (en-GB) locale strings can be found [here](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json). The custom locale format is the same as this json file.
-For example, to override the `actions.add_directory` label (which is `Add Directory` by default), you would have the following in the custom locale:
+For example, to override the `actions.add_directory` label (which is `Add directory` by default), you would have the following in the custom locale:
```
{
diff --git a/ui/v2.5/src/docs/en/Manual/Introduction.md b/ui/v2.5/src/docs/en/Manual/Introduction.md
index 1496ad2b1..f32b84681 100644
--- a/ui/v2.5/src/docs/en/Manual/Introduction.md
+++ b/ui/v2.5/src/docs/en/Manual/Introduction.md
@@ -2,6 +2,8 @@
Stash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and stash will begin scanning and importing your media into its library.
-For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). Note that currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete.
+For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks).
+
+> **⚠️ Note:** Currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete.
Once your media is imported, you are ready to begin creating Performers, Studios and Tags, and curating your content!
\ No newline at end of file
diff --git a/ui/v2.5/src/docs/en/Manual/JSONSpec.md b/ui/v2.5/src/docs/en/Manual/JSONSpec.md
index 0a53d09f2..b071f26cc 100644
--- a/ui/v2.5/src/docs/en/Manual/JSONSpec.md
+++ b/ui/v2.5/src/docs/en/Manual/JSONSpec.md
@@ -24,7 +24,7 @@ When exported, files are named with different formats depending on the object ty
| Studios | `.json` |
| Groups | `.json` |
-Note that the file naming is not significant when importing. All json files will be read from the subdirectories.
+> **⚠️ Note:** The file naming is not significant when importing. All json files will be read from the subdirectories.
## Content of the json files
diff --git a/ui/v2.5/src/docs/en/Manual/Plugins.md b/ui/v2.5/src/docs/en/Manual/Plugins.md
index cd24e0d4a..5e403af92 100644
--- a/ui/v2.5/src/docs/en/Manual/Plugins.md
+++ b/ui/v2.5/src/docs/en/Manual/Plugins.md
@@ -240,7 +240,7 @@ hooks:
argKey: argValue
```
-**Note:** it is possible for hooks to trigger eachother or themselves if they perform mutations. For safety, hooks will not be triggered if they have already been triggered in the context of the operation. Stash uses cookies to track this context, so it's important for plugins to send cookies when performing operations.
+**⚠️ Note:** It is possible for hooks to trigger eachother or themselves if they perform mutations. For safety, hooks will not be triggered if they have already been triggered in the context of the operation. Stash uses cookies to track this context, so it's important for plugins to send cookies when performing operations.
#### Trigger types
diff --git a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md
index 1f52028f8..4c97e3fcf 100644
--- a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md
+++ b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md
@@ -375,7 +375,7 @@ scene:
selector: //div[@data-host="{inputHostname}"]//span[@class="site-name"]
```
-> **Note:** These placeholders represent the actual URL used to fetch the content, after any URL replacements have been applied.
+> **⚠️ Note:** These placeholders represent the actual URL used to fetch the content, after any URL replacements have been applied.
### Common fragments
@@ -391,6 +391,7 @@ performer:
The `Measurements` xpath string will replace `$infoPiece` with `//div[@class="infoPiece"]/span`, resulting in: `//div[@class="infoPiece"]/span[text() = 'Measurements:']/../span[@class="smallInfo"]`.
> **⚠️ Note:** Recursive common fragments are **not** supported.
+
Referencing a common fragment within another common fragment will cause an error. For example:
```yaml
common:
@@ -881,7 +882,7 @@ Title
URLs
```
-> **Important**: `Title` field is required.
+> **⚠️ Important:** `Title` field is required.
### Group
@@ -900,7 +901,7 @@ Tags (see Tag fields)
URLs
```
-> **Important**: `Name` field is required.
+> **⚠️ Important:** `Name` field is required.
### Image
@@ -944,9 +945,9 @@ URLs
Weight
```
-> **Important**: `Name` field is required.
+> **⚠️ Important:** `Name` field is required.
-> **Note:** - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive).
+> **⚠️ Note:** `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive).
### Scene
@@ -964,7 +965,7 @@ Title
URLs
```
-> **Important**: `Title` field is required only if fileless.
+> **⚠️ Important:** `Title` field is required only if fileless.
### Studio
@@ -976,7 +977,7 @@ Tags (see Tag fields)
URL
```
-> **Important**: `Name` field is required.
+> **⚠️ Important:** `Name` field is required.
### Tag
@@ -984,4 +985,4 @@ URL
Name
```
-> **Important**: `Name` field is required.
+> **⚠️ Important:** `Name` field is required.
diff --git a/ui/v2.5/src/docs/en/Manual/Tagger.md b/ui/v2.5/src/docs/en/Manual/Tagger.md
index ba9e5f17a..7c2d12a87 100644
--- a/ui/v2.5/src/docs/en/Manual/Tagger.md
+++ b/ui/v2.5/src/docs/en/Manual/Tagger.md
@@ -4,9 +4,9 @@ Stash can be integrated with stash-box which acts as a centralized metadata data
## Searching
-The fingerprint search matches your current selection of files against the remote stash-box instance. Any scenes with a matching fingerprint will be returned, although there is currently no validation of fingerprints so it’s recommended to double-check the validity before saving.
+The fingerprint search matches your current selection of files against the remote stash-box instance. Any scenes with a matching fingerprint will be returned, although there is currently no validation of fingerprints so it's recommended to double-check the validity before saving.
-If no fingerprint match is found it’s possible to search by keywords. The search works by matching the query against a scene’s _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config.
+If no fingerprint match is found it's possible to search by keywords. The search works by matching the query against a scene's _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config.
An important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `"A Trip to the Mall"` with the performer `"Jane Doe"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall doe"` would. Usually a few pieces of info is enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query.
diff --git a/ui/v2.5/src/docs/en/Manual/Tasks.md b/ui/v2.5/src/docs/en/Manual/Tasks.md
index aa46f72bb..4191afd24 100644
--- a/ui/v2.5/src/docs/en/Manual/Tasks.md
+++ b/ui/v2.5/src/docs/en/Manual/Tasks.md
@@ -16,12 +16,13 @@ The scan task accepts the following options:
|--------|-------------|
| Generate scene covers | Generates scene covers for video files. |
| Generate previews | Generates video previews (mp4) which play when hovering over a scene. |
-| Generate animated image previews* | *Accessible in Advanced Mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.|
+| Generate animated image previews | *Accessible in Advanced mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.|
| Generate scrubber sprites | The set of images displayed below the video player for easy navigation. |
-| Generate perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
+| Generate video perceptual hashes | Generates perceptual hashes for scene deduplication and identification. |
| Generate thumbnails for images | Generates thumbnails for image files. |
+| Generate image perceptual hashes | Generates perceptual hashes for image deduplication and identification. |
| Generate previews for image clips | Generates a gif/looping video as thumbnail for image clips/gifs. |
-| Rescan | By default, Stash will only rescan existing files if the file's modified date has been updated since its previous scan. Stash will rescan files in the path when this option is enabled, regardless of the file modification time. Only required Stash needs to recalculate video/image metadata, or to rescan gallery zips. |
+| Rescan | By default, Stash will only rescan existing files if the file's modified date has been updated since its previous scan. Stash will rescan files in the path when this option is enabled, regardless of the file modification time. Only required if Stash needs to recalculate video/image metadata, or to rescan gallery zips. |
## Auto Tagging
See the [Auto Tagging](/help/AutoTagging.md) page.
@@ -31,14 +32,16 @@ See the [Scene Filename Parser](/help/SceneFilenameParser.md) page.
## Generated Content
-The scanning function automatically generates a screenshot of each scene. The generated content provides the following:
+The generated content provides the following:
+* Scene covers - screenshot of the scene used as the cover image
* Video or image previews that are played when mousing over the scene card
-* Perceptual hashes - helps match against StashDB, and feeds the duplicate finder
+* Video Perceptual hashes - helps match against StashDB, and feeds the duplicate finder
* Sprites (scene stills for parts of each scene) that are shown in the scene scrubber
* Marker video previews that are shown in the markers page
* Transcoded versions of scenes. See below
* Image thumbnails of galleries
+* Image Perceptual hashes - can be used for identification and deduplication
The generate task accepts the following options:
@@ -46,15 +49,17 @@ The generate task accepts the following options:
|--------|-------------|
| Scene covers | Generates scene covers for video files. |
| Previews | Generates video previews (mp4) which play when hovering over a scene. |
-| Animated image previews | *Accessible in Advanced Mode* - Generates animated previews (webp). Only required if the Preview Type is set to Animated Image. Requires Generate previews to be enabled. |
-| Scene Scrubber Sprites | The set of images displayed below the video player for easy navigation. |
-| Markers Previews | Generates 20 second video previews (mp4) which begin at the marker timecode. |
-| Marker Animated Image Previews | *Accessible in Advanced Mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files. |
-| Marker Screenshots | Generates static JPG images for markers. Only required if Preview Type is set to Static Image. Requires Marker Previews to be enabled. |
-| Transcodes | *Accessible in Advanced Mode* - MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. |
-| Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. |
+| Animated image previews | *Accessible in Advanced mode* - Generates animated previews (webp). Only required if the Preview type is set to Animated image. Requires Generate previews to be enabled. |
+| Scene scrubber sprites | The set of images displayed below the video player for easy navigation. |
+| Marker previews | Generates 20 second video previews (mp4) which begin at the marker timecode. |
+| Marker animated image previews | *Accessible in Advanced mode* - Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files. |
+| Marker screenshots | Generates static JPG images for markers. Only required if Preview type is set to Static image. Requires marker previews to be enabled. |
+| Transcodes | *Accessible in Advanced mode* - MP4 conversions of unsupported video formats. Allows direct streaming instead of live transcoding. |
+| Video Perceptual hashes (for deduplication) | Generates perceptual hashes for scene deduplication and identification. |
| Generate heatmaps and speeds for interactive scenes | Generates heatmaps and speeds for interactive scenes. |
-| Image Clip Previews | Generates a gif/looping video as thumbnail for image clips/gifs. |
+| Image clip previews | Generates a gif/looping video as thumbnail for image clips/gifs. |
+| Image thumbnails | Generates thumbnails for image files. |
+| Image Perceptual hashes (for deduplication) | Generates perceptual hashes for image deduplication and identification. |
| Overwrite existing generated files | By default, where a generated file exists, it is not regenerated. When this flag is enabled, then the generated files are regenerated. |
### Transcodes
@@ -80,3 +85,19 @@ The import and export tasks read and write JSON files to the configured metadata
> **⚠️ Note:** The full import task wipes the current database completely before importing.
See the [JSON Specification](/help/JSONSpec.md) page for details on the exported JSON format.
+
+## Backing up
+
+The backup task creates a backup of the stash database and (optionally) blob files. The backup can either be downloaded or output into the backup directory (under `Settings > Paths`) or the database directory if the backup directory is not configured.
+
+For a full backup, the database file and all blob files must be copied. The backup is stored as a zip file, with the database file at the root of the zip and the blob files in a `blobs` directory.
+
+> **⚠️ Note:** generated files are not included in the backup, so these will need to be regenerated when restoring with an empty system from backup.
+
+For database-only backups, only the database file is copied into the destination. This is useful for quick backups before performing risky operations, or for users who do not use filesystem blob storage.
+
+## Restoring from backup
+
+Restoring from backup is currently a manual process. The database backup zip file must be unzipped, and the database file and blob files (if applicable) copied into the database and blob directories respectively. Stash should then be restarted to load the restored database.
+
+> **⚠️ Note:** the filename for a database-only backup is not the same as the original database file, so the database file from the backup must be renamed to match the original database filename before copying it into the database directory. The original database filename can be found in `Settings > Paths > Database path`.
\ No newline at end of file
diff --git a/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md
new file mode 100644
index 000000000..9a5ffd215
--- /dev/null
+++ b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md
@@ -0,0 +1,7 @@
+# Troubleshooting Mode
+
+Troubleshooting mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue.
+
+Troubleshooting mode is enabled from the Settings page, by clicking the `Troubleshooting mode` button at the bottom left of the page.
+
+When Troubleshooting mode is enabled, a red border and a banner will be displayed to remind you that you are in Troubleshooting mode. To exit Troubleshooting mode, click the `Exit` button in the banner.
\ No newline at end of file
diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md
index e1347a46f..54ef3a20f 100644
--- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md
+++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md
@@ -33,6 +33,7 @@ This namespace contains the generated graphql client interface. This is a low-le
- `FontAwesomeBrands`
- `Mousetrap`
- `MousetrapPause`
+- `ReactFontAwesome`
- `ReactSelect`
### `register`
@@ -228,6 +229,8 @@ Returns `void`.
- `DetailImage`
- `ExternalLinkButtons`
- `ExternalLinksButton`
+- `FilteredGalleryList`
+- `FilteredSceneList`
- `FolderSelect`
- `FrontPage`
- `GalleryCard`
@@ -235,17 +238,31 @@ Returns `void`.
- `GalleryCard.Image`
- `GalleryCard.Overlays`
- `GalleryCard.Popovers`
+- `GalleryCardGrid`
- `GalleryIDSelect`
+- `GalleryList`
+- `GalleryRecommendationRow`
- `GallerySelect`
- `GallerySelect.sort`
+- `GridCard`
+- `GroupCard`
+- `GroupCardGrid`
- `GroupIDSelect`
+- `GroupRecommendationRow`
- `GroupSelect`
- `GroupSelect.sort`
- `HeaderImage`
- `HoverPopover`
- `Icon`
+- `ImageCard`
+- `ImageCard.Details`
+- `ImageCard.Image`
+- `ImageCard.Overlays`
+- `ImageCard.Popovers`
- `ImageDetailPanel`
+- `ImageGridCard`
- `ImageInput`
+- `ImageRecommendationRow`
- `LightboxLink`
- `LoadingIndicator`
- `MainNavBar.MenuItems`
@@ -261,6 +278,7 @@ Returns `void`.
- `PerformerCard.Overlays`
- `PerformerCard.Popovers`
- `PerformerCard.Title`
+- `PerformerCardGrid`
- `PerformerDetailsPanel`
- `PerformerDetailsPanel.DetailGroup`
- `PerformerGalleriesPanel`
@@ -269,6 +287,7 @@ Returns `void`.
- `PerformerIDSelect`
- `PerformerImagesPanel`
- `PerformerPage`
+- `PerformerRecommendationRow`
- `PerformerScenesPanel`
- `PerformerSelect`
- `PerformerSelect.sort`
@@ -277,17 +296,27 @@ Returns `void`.
- `RatingNumber`
- `RatingStars`
- `RatingSystem`
+- `RecommendationRow`
- `SceneCard`
- `SceneCard.Details`
- `SceneCard.Image`
- `SceneCard.Overlays`
- `SceneCard.Popovers`
+- `SceneCardsGrid`
- `SceneFileInfoPanel`
- `SceneIDSelect`
+- `SceneMarkerCard`
+- `SceneMarkerCard.Details`
+- `SceneMarkerCard.Image`
+- `SceneMarkerCard.Popovers`
+- `SceneMarkerCardsGrid`
+- `SceneMarkerRecommendationRow`
+- `SceneList`
- `ScenePage`
- `ScenePage.TabContent`
- `ScenePage.Tabs`
- `ScenePlayer`
+- `SceneRecommendationRow`
- `SceneSelect`
- `SceneSelect.sort`
- `SelectSetting`
@@ -296,7 +325,11 @@ Returns `void`.
- `SettingModal`
- `StringListSetting`
- `StringSetting`
+- `StudioCard`
+- `StudioCardGrid`
+- `StudioDetailsPanel`
- `StudioIDSelect`
+- `StudioRecommendationRow`
- `StudioSelect`
- `StudioSelect.sort`
- `SweatDrops`
@@ -307,8 +340,10 @@ Returns `void`.
- `TagCard.Overlays`
- `TagCard.Popovers`
- `TagCard.Title`
+- `TagCardGrid`
- `TagIDSelect`
- `TagLink`
+- `TagRecommendationRow`
- `TagSelect`
- `TagSelect.sort`
- `TruncatedText`
@@ -319,6 +354,4 @@ Allows plugins to listen for Stash's events.
```js
PluginApi.Event.addEventListener("stash:location", (e) => console.log("Page Changed", e.detail.data.location.pathname))
-```
-
-
+```
\ No newline at end of file
diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss
index 0c0bffdec..cadd1ad2f 100755
--- a/ui/v2.5/src/index.scss
+++ b/ui/v2.5/src/index.scss
@@ -526,8 +526,6 @@ textarea.text-input {
}
.zoom-1 {
- width: 320px;
-
.gallery-card-image,
.tag-card-image {
height: 240px;
@@ -1438,3 +1436,40 @@ select {
h3 .TruncatedText {
line-height: 1.5;
}
+
+// Troubleshooting Mode overlay banner
+.troubleshooting-mode-overlay {
+ border: 5px solid $danger;
+ bottom: 0;
+ left: 0;
+ opacity: 0.75;
+ pointer-events: none;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: 1040;
+
+ .troubleshooting-mode-alert {
+ align-items: baseline;
+ border-radius: 0;
+ bottom: 0.5rem;
+ display: inline-flex;
+ margin: 0;
+ position: fixed;
+ right: 0.5rem;
+
+ @include media-breakpoint-down(xs) {
+ @media (orientation: portrait) {
+ bottom: $navbar-height;
+
+ & > span {
+ font-size: 0.75rem;
+ }
+ }
+ }
+ }
+
+ .btn {
+ pointer-events: auto;
+ }
+}
diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json
index 704373fb0..f5451249e 100644
--- a/ui/v2.5/src/locales/en-GB.json
+++ b/ui/v2.5/src/locales/en-GB.json
@@ -1,7 +1,7 @@
{
"actions": {
"add": "Add",
- "add_directory": "Add Directory",
+ "add_directory": "Add directory",
"add_entity": "Add {entityType}",
"add_manual_date": "Add manual date",
"add_sub_groups": "Add Sub-Groups",
@@ -14,7 +14,7 @@
"anonymise": "Anonymise",
"apply": "Apply",
"assign_stashid_to_parent_studio": "Assign Stash ID to existing parent studio and update metadata",
- "auto_tag": "Auto Tag",
+ "auto_tag": "Auto tag",
"backup": "Backup",
"browse_for_image": "Browse for image…",
"cancel": "Cancel",
@@ -47,7 +47,7 @@
"disallow": "Disallow",
"download": "Download",
"download_anonymised": "Download anonymised",
- "download_backup": "Download Backup",
+ "download_backup": "Download backup",
"edit": "Edit",
"edit_entity": "Edit {entityType}",
"enable": "Enable",
@@ -58,8 +58,8 @@
"finish": "Finish",
"from_file": "From file…",
"from_url": "From URL…",
- "full_export": "Full Export",
- "full_import": "Full Import",
+ "full_export": "Full export",
+ "full_import": "Full import",
"generate": "Generate",
"generate_thumb_default": "Generate default thumbnail",
"generate_thumb_from_current": "Generate thumbnail from current",
@@ -75,13 +75,13 @@
"logout": "Log out",
"make_primary": "Make Primary",
"merge": "Merge",
- "migrate_blobs": "Migrate Blobs",
- "migrate_scene_screenshots": "Migrate Scene Screenshots",
+ "migrate_blobs": "Migrate blobs",
+ "migrate_scene_screenshots": "Migrate scene screenshots",
"next_action": "Next",
"not_running": "not running",
"open_in_external_player": "Open in external player",
"open_random": "Open Random",
- "optimise_database": "Optimise Database",
+ "optimise_database": "Optimise database",
"overwrite": "Overwrite",
"play": "Play",
"play_random": "Play Random",
@@ -115,13 +115,14 @@
"scrape_with": "Scrape with…",
"search": "Search",
"select_all": "Select All",
+ "select_directory": "Select directory",
"select_entity": "Select {entityType}",
"select_folders": "Select folders",
"select_none": "Select None",
"invert_selection": "Invert Selection",
- "selective_auto_tag": "Selective Auto Tag",
- "selective_clean": "Selective Clean",
- "selective_scan": "Selective Scan",
+ "selective_auto_tag": "Selective auto tag",
+ "selective_clean": "Selective clean",
+ "selective_scan": "Selective scan",
"set_as_default": "Set as default",
"set_back_image": "Back image…",
"set_cover": "Set as Cover",
@@ -174,7 +175,9 @@
"filesystem": "Filesystem"
},
"captions": "Captions",
+ "career_end": "Career End",
"career_length": "Career Length",
+ "career_start": "Career Start",
"chapters": "Chapters",
"circumcised": "Circumcised",
"circumcised_types": {
@@ -252,7 +255,7 @@
"stash_wiki": "Stash {url} page",
"version": "Version"
},
- "advanced_mode": "Advanced Mode",
+ "advanced_mode": "Advanced mode",
"application_paths": {
"heading": "Application Paths"
},
@@ -270,11 +273,14 @@
"tasks": "Tasks",
"tools": "Tools"
},
+ "changelog": {
+ "header": "Changelog"
+ },
"dlna": {
"allow_temp_ip": "Allow {tempIP}",
"allowed_ip_addresses": "Allowed IP addresses",
"allowed_ip_temporarily": "Allowed IP temporarily",
- "default_ip_whitelist": "Default IP Whitelist",
+ "default_ip_whitelist": "Default IP whitelist",
"default_ip_whitelist_desc": "Default IP addresses allow to access DLNA. Use {wildcard} to allow all IP addresses.",
"disabled_dlna_temporarily": "Disabled DLNA temporarily",
"disallowed_ip": "Disallowed IP",
@@ -283,35 +289,35 @@
"network_interfaces": "Interfaces",
"network_interfaces_desc": "Interfaces to expose DLNA server on. An empty list results in running on all interfaces. Requires DLNA restart after changing.",
"recent_ip_addresses": "Recent IP addresses",
- "server_display_name": "Server Display Name",
+ "server_display_name": "Server display name",
"server_display_name_desc": "Display name for the DLNA server. Defaults to {server_name} if empty.",
- "server_port": "Server Port",
+ "server_port": "Server port",
"server_port_desc": "Port to run the DLNA server on. Requires DLNA restart after changing.",
"successfully_cancelled_temporary_behaviour": "Successfully cancelled temporary behaviour",
"until_restart": "until restart",
- "video_sort_order": "Default Video Sort Order",
+ "video_sort_order": "Default video sort order",
"video_sort_order_desc": "Order to sort videos by default."
},
"general": {
"auth": {
- "api_key": "API Key",
+ "api_key": "API key",
"api_key_desc": "API key for external systems. Only required when username/password is configured. Username must be saved before generating API key.",
"authentication": "Authentication",
"clear_api_key": "Clear API key",
"credentials": {
- "description": "Credentials to restrict access to stash.",
+ "description": "Credentials to restrict access to Stash.",
"heading": "Credentials"
},
"generate_api_key": "Generate API key",
"log_file": "Log file",
"log_file_desc": "Path to the file to output logging to. Blank to disable file logging. Requires restart.",
- "log_http": "Log http access",
- "log_http_desc": "Logs http access to the terminal. Requires restart.",
+ "log_http": "Log HTTP access",
+ "log_http_desc": "Logs HTTP access to the terminal. Requires restart.",
"log_to_terminal": "Log to terminal",
"log_to_terminal_desc": "Logs to the terminal in addition to a file. Always true if file logging is disabled. Requires restart.",
"log_file_max_size": "Maximum log size",
"log_file_max_size_desc": "Maximum size in megabytes of the log file before it is compressed. 0MB is disabled. Requires restart.",
- "maximum_session_age": "Maximum Session Age",
+ "maximum_session_age": "Maximum session age",
"maximum_session_age_desc": "Maximum idle time before a login session is expired, in seconds. Requires restart.",
"password": "Password",
"password_desc": "Password to access Stash. Leave blank to disable user authentication",
@@ -320,50 +326,50 @@
"username_desc": "Username to access Stash. Leave blank to disable user authentication"
},
"backup_directory_path": {
- "description": "Directory location for SQLite database file backups",
- "heading": "Backup Directory Path"
+ "description": "Directory location for SQLite database file backups.",
+ "heading": "Backup directory path"
},
"delete_trash_path": {
"description": "Path where deleted files will be moved to instead of being permanently deleted. Leave empty to permanently delete files.",
- "heading": "Trash Path"
+ "heading": "Trash path"
},
"blobs_path": {
"description": "Where in the filesystem to store binary data. Applicable only when using the Filesystem blob storage type. WARNING: changing this requires manually moving existing data.",
"heading": "Binary data filesystem path"
},
"blobs_storage": {
- "description": "Where to store binary data such as scene covers, performer, studio and tag images. After changing this value, the existing data must be migrated using the Migrate Blobs tasks. See Tasks page for migration.",
+ "description": "Where to store binary data such as scene covers, performer, studio and tag images. After changing this value, the existing data must be migrated using the Migrate blobs tasks. See Tasks page for migration.",
"heading": "Binary data storage type"
},
"cache_location": "Directory location of the cache. Required if streaming using HLS (such as on Apple devices) or DASH.",
- "cache_path_head": "Cache Path",
+ "cache_path_head": "Cache path",
"calculate_md5_and_ohash_desc": "Calculate MD5 checksum in addition to oshash. Enabling will cause initial scans to be slower. File naming hash must be set to oshash to disable MD5 calculation.",
"calculate_md5_and_ohash_label": "Calculate MD5 for videos",
"check_for_insecure_certificates": "Check for insecure certificates",
- "check_for_insecure_certificates_desc": "Some sites use insecure ssl certificates. When unticked the scraper skips the insecure certificates check and allows scraping of those sites. If you get a certificate error when scraping untick this.",
+ "check_for_insecure_certificates_desc": "Some sites use insecure SSL certificates. When unticked the scraper skips the insecure certificates check and allows scraping of those sites. If you get a certificate error when scraping untick this.",
"chrome_cdp_path": "Chrome CDP path",
"chrome_cdp_path_desc": "File path to the Chrome executable, or a remote address (starting with http:// or https://, for example http://localhost:9222/json/version) to a Chrome instance.",
- "create_galleries_from_folders_desc": "If true, creates galleries from folders containing images by default. Create a File called .forcegallery or .nogallery in a folder to enforce/prevent this.",
+ "create_galleries_from_folders_desc": "If true, creates galleries from folders containing images by default. Create a file called .forcegallery or .nogallery in a folder to override this setting.",
"create_galleries_from_folders_label": "Create galleries from folders containing images",
"database": "Database",
- "db_path_head": "Database Path",
+ "db_path_head": "Database path",
"directory_locations_to_your_content": "Directory locations to your content",
- "excluded_image_gallery_patterns_desc": "Regexps of image and gallery files/paths to exclude from Scan and add to Clean",
- "excluded_image_gallery_patterns_head": "Excluded Image/Gallery Patterns",
- "excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean",
- "excluded_video_patterns_head": "Excluded Video Patterns",
+ "excluded_image_gallery_patterns_desc": "Regexps of image and gallery files/paths to exclude from Scan and add to Clean tasks.",
+ "excluded_image_gallery_patterns_head": "Excluded image/gallery patterns",
+ "excluded_video_patterns_desc": "Regexps of video files/paths to exclude from Scan and add to Clean tasks.",
+ "excluded_video_patterns_head": "Excluded video patterns",
"ffmpeg": {
"download_ffmpeg": {
"description": "Downloads FFmpeg into the configuration directory and clears the ffmpeg and ffprobe paths to resolve from the configuration directory.",
"heading": "Download FFmpeg"
},
"ffmpeg_path": {
- "description": "Path to the ffmpeg executable (not just the folder). If empty, ffmpeg will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash",
- "heading": "FFmpeg Executable Path"
+ "description": "Path to the ffmpeg executable (not just the folder). If empty, ffmpeg will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash.",
+ "heading": "FFmpeg executable path"
},
"ffprobe_path": {
- "description": "Path to the ffprobe executable (not just the folder). If empty, ffprobe will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash",
- "heading": "FFprobe Executable Path"
+ "description": "Path to the ffprobe executable (not just the folder). If empty, ffprobe will be resolved from the environment via $PATH, the configuration directory, or from $HOME/.stash.",
+ "heading": "FFprobe executable path"
},
"hardware_acceleration": {
"desc": "Uses available hardware to encode video for live transcoding.",
@@ -372,80 +378,91 @@
"live_transcode": {
"input_args": {
"desc": "Advanced: Additional arguments to pass to ffmpeg before the input field when live transcoding video.",
- "heading": "FFmpeg Live Transcode Input Args"
+ "heading": "FFmpeg live transcode input arguments"
},
"output_args": {
"desc": "Advanced: Additional arguments to pass to ffmpeg before the output field when live transcoding video.",
- "heading": "FFmpeg Live Transcode Output Args"
+ "heading": "FFmpeg live transcode output arguments"
}
},
"transcode": {
"input_args": {
"desc": "Advanced: Additional arguments to pass to ffmpeg before the input field when generating video.",
- "heading": "FFmpeg Transcode Input Args"
+ "heading": "FFmpeg transcode input arguments"
},
"output_args": {
"desc": "Advanced: Additional arguments to pass to ffmpeg before the output field when generating video.",
- "heading": "FFmpeg Transcode Output Args"
+ "heading": "FFmpeg transcode output arguments"
}
}
},
"funscript_heatmap_draw_range": "Include range in generated heatmaps",
"funscript_heatmap_draw_range_desc": "Draw range of motion on the y-axis of the generated heatmap. Existing heatmaps will need to be regenerated after changing.",
- "gallery_cover_regex_desc": "Regexp used to identify an image as gallery cover",
+ "gallery_cover_regex_desc": "Regexps used to identify an image as gallery cover.",
"gallery_cover_regex_label": "Gallery cover pattern",
- "gallery_ext_desc": "Comma-delimited list of file extensions that will be identified as gallery zip files.",
- "gallery_ext_head": "Gallery zip Extensions",
+ "gallery_ext_desc": "Comma-delimited list of file extensions that will be identified as gallery ZIP files.",
+ "gallery_ext_head": "Gallery ZIP extensions",
"generated_file_naming_hash_desc": "Use MD5 or oshash for generated file naming. Changing this requires that all scenes have the applicable MD5/oshash value populated. After changing this value, existing generated files will need to be migrated or regenerated. See Tasks page for migration.",
"generated_file_naming_hash_head": "Generated file naming hash",
- "generated_files_location": "Directory location for the generated files (scene markers, scene previews, sprites, etc)",
- "generated_path_head": "Generated Path",
+ "generated_files_location": "Directory location for the generated files (scene markers, scene previews, sprites, etc).",
+ "generated_path_head": "Generated path",
"hashing": "Hashing",
"heatmap_generation": "Funscript Heatmap Generation",
"image_ext_desc": "Comma-delimited list of file extensions that will be identified as images.",
- "image_ext_head": "Image Extensions",
+ "image_ext_head": "Image extensions",
"include_audio_desc": "Includes audio stream when generating previews.",
"include_audio_head": "Include audio",
"logging": "Logging",
- "maximum_streaming_transcode_size_desc": "Maximum size for transcoded streams",
+ "maximum_streaming_transcode_size_desc": "Maximum size for transcoded streams.",
"maximum_streaming_transcode_size_head": "Maximum streaming transcode size",
- "maximum_transcode_size_desc": "Maximum size for generated transcodes",
+ "maximum_transcode_size_desc": "Maximum size for generated transcodes.",
"maximum_transcode_size_head": "Maximum transcode size",
"metadata_path": {
- "description": "Directory location used when performing a full export or import",
- "heading": "Metadata Path"
+ "description": "Directory location used when performing a full export or import.",
+ "heading": "Metadata path"
},
- "number_of_parallel_task_for_scan_generation_desc": "Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% cpu utilisation will decrease performance and potentially cause other issues.",
+ "number_of_parallel_task_for_scan_generation_desc": "Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% CPU utilisation will decrease performance and potentially cause other issues.",
"number_of_parallel_task_for_scan_generation_head": "Number of parallel task for scan/generation",
"parallel_scan_head": "Parallel Scan/Generation",
"plugins_path": {
- "description": "Directory location of plugin configuration files",
- "heading": "Plugins Path"
+ "description": "Directory location of plugin configuration files.",
+ "heading": "Plugins path"
},
"preview_generation": "Preview Generation",
"python_path": {
- "description": "Path to the python executable (not just the folder). Used for script scrapers and plugins. If blank, python will be resolved from the environment",
- "heading": "Python Executable Path"
+ "description": "Path to the python executable (not just the folder). Used for script scrapers and plugins. If blank, Python will be resolved from the environment.",
+ "heading": "Python executable path"
},
- "scraper_user_agent": "Scraper User Agent",
- "scraper_user_agent_desc": "User-Agent string used during scrape http requests",
+ "scraper_user_agent": "Scraper User-Agent",
+ "scraper_user_agent_desc": "User-Agent string used during scrape HTTP requests.",
"scrapers_path": {
- "description": "Directory location of scraper configuration files",
- "heading": "Scrapers Path"
+ "description": "Directory location of scraper configuration files.",
+ "heading": "Scrapers path"
},
"scraping": "Scraping",
+ "sprite_generation_head": "Sprite generation",
+ "sprite_interval_desc": "Time between each generated sprite in seconds.",
+ "sprite_interval_head": "Sprite interval",
+ "sprite_maximum_desc": "Maximum number of sprites to be generated for a scene. Set to 0 to disable the limit.",
+ "sprite_maximum_head": "Maximum sprites",
+ "sprite_minimum_desc": "Minimum number of sprites to be generated for a scene",
+ "sprite_minimum_head": "Minimum sprites",
+ "sprite_screenshot_size_desc": "Desired size of each sprite in pixels.",
+ "sprite_screenshot_size_head": "Sprite size",
"sqlite_location": "File location for the SQLite database (requires restart). WARNING: storing the database on a different system to where the Stash server is run from (i.e. over the network) is unsupported!",
+ "use_custom_sprite_interval_head": "Use custom sprite interval",
+ "use_custom_sprite_interval_desc": "Enable the custom sprite interval according to the settings below.",
"video_ext_desc": "Comma-delimited list of file extensions that will be identified as videos.",
- "video_ext_head": "Video Extensions",
+ "video_ext_head": "Video extensions",
"video_head": "Video"
},
"library": {
"exclusions": "Exclusions",
- "gallery_and_image_options": "Gallery and Image options",
- "media_content_extensions": "Media content extensions"
+ "gallery_and_image_options": "Gallery and Image Options",
+ "media_content_extensions": "Media Content Extensions"
},
"logs": {
- "log_level": "Log Level"
+ "log_level": "Log level"
},
"plugins": {
"available_plugins": "Available Plugins",
@@ -457,8 +474,8 @@
"available_scrapers": "Available Scrapers",
"entity_metadata": "{entityType} Metadata",
"entity_scrapers": "{entityType} scrapers",
- "excluded_tag_patterns_desc": "Regexps of tag names to exclude from scraping results",
- "excluded_tag_patterns_head": "Excluded Tag Patterns",
+ "excluded_tag_patterns_desc": "Regexps of tag names to exclude from scraping results.",
+ "excluded_tag_patterns_head": "Excluded tag patterns",
"installed_scrapers": "Installed Scrapers",
"scraper": "Scraper",
"scrapers": "Scrapers",
@@ -486,25 +503,35 @@
"anonymise_database": "Makes a copy of the database to the backups directory, anonymising all sensitive data. This can then be provided to others for troubleshooting and debugging purposes. The original database is not modified. Anonymised database uses the filename format {filename_format}.",
"anonymising_database": "Anonymising database",
"auto_tag": {
- "auto_tagging_all_paths": "Auto Tagging all paths",
- "auto_tagging_paths": "Auto Tagging the following paths"
+ "auto_tagging_all_paths": "Auto tagging all paths",
+ "auto_tagging_paths": "Auto tagging the following paths"
},
- "auto_tag_based_on_filenames": "Auto-tag content based on file paths.",
- "auto_tagging": "Auto Tagging",
+ "auto_tag_based_on_filenames": "Auto tag content based on file paths.",
+ "auto_tagging": "Auto tagging",
"backing_up_database": "Backing up database",
"backup_and_download": "Performs a backup of the database and downloads the resulting file.",
- "backup_database": "Performs a backup of the database to the backups directory, with the filename format {filename_format}",
+ "backup_database": {
+ "description": "Performs a backup of the database and blob files.",
+ "destination": "Destination",
+ "download": "Download backup",
+ "include_blobs": "Include blobs in backup",
+ "include_blobs_desc": "Disable to only backup the SQLite database file.",
+ "sqlite": "Backup file will be a copy of the SQLite database file, with the filename {filename_format}",
+ "to_directory": "To {directory}",
+ "warning_blobs": "Blob files will not be included in the backup. This means that to succesfully restore from the backup, the blob files must be present in the blob storage location.",
+ "zip": "SQLite database file and blob files will be zipped into a single file, with the filename {filename_format}"
+ },
"cleanup_desc": "Check for missing files and remove them from the database. This is a destructive action.",
"clean_generated": {
"blob_files": "Blob files",
"description": "Removes generated files without a corresponding database entry.",
- "image_thumbnails": "Image Thumbnails",
+ "image_thumbnails": "Image thumbnails",
"image_thumbnails_desc": "Image thumbnails and clips",
- "markers": "Marker Previews",
- "previews": "Scene Previews",
+ "markers": "Marker previews",
+ "previews": "Scene previews",
"previews_desc": "Scene previews and thumbnails",
- "sprites": "Scene Sprites",
- "transcodes": "Scene Transcodes"
+ "sprites": "Scene sprites",
+ "transcodes": "Scene transcodes"
},
"data_management": "Data management",
"defaults_set": "Defaults have been set and will be used when clicking the {action} button on the Tasks page.",
@@ -517,10 +544,12 @@
},
"generate_clip_previews_during_scan": "Generate previews for image clips",
"generate_desc": "Generate supporting image, sprite, video, vtt and other files.",
- "generate_phashes_during_scan": "Generate perceptual hashes",
+ "generate_image_phashes_during_scan": "Generate image perceptual hashes",
+ "generate_image_phashes_during_scan_tooltip": "For deduplication and identification.",
+ "generate_phashes_during_scan": "Generate video perceptual hashes",
"generate_phashes_during_scan_tooltip": "For deduplication and scene identification.",
"generate_previews_during_scan": "Generate animated image previews",
- "generate_previews_during_scan_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
+ "generate_previews_during_scan_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
"generate_sprites_during_scan": "Generate scrubber sprites",
"generate_sprites_during_scan_tooltip": "The set of images displayed below the video player for easy navigation.",
"generate_thumbnails_during_scan": "Generate thumbnails for images",
@@ -541,6 +570,8 @@
"identifying_from_paths": "Identifying scenes from the following paths",
"identifying_scenes": "Identifying {num} {scene}",
"include_male_performers": "Include male performers",
+ "performer_genders": "Performer genders",
+ "performer_genders_desc": "Performers with selected genders will be included during identification.",
"set_cover_images": "Set cover images",
"set_organized": "Set organised flag",
"skip_multiple_matches": "Skip matches that have more than one result",
@@ -587,7 +618,7 @@
"tools": {
"graphql_playground": "GraphQL playground",
"heading": "Tools",
- "scene_duplicate_checker": "Scene Duplicate Checker",
+ "scene_duplicate_checker": "Scene duplicate checker",
"scene_filename_parser": {
"add_field": "Add Field",
"capitalize_title": "Capitalize title",
@@ -599,7 +630,7 @@
"ignored_words": "Ignored words",
"matches_with": "Matches with {i}",
"select_parser_recipe": "Select Parser Recipe",
- "title": "Scene Filename Parser",
+ "title": "Scene filename parser",
"whitespace_chars": "Whitespace characters",
"whitespace_chars_desc": "These characters will be replaced with whitespace in the title"
},
@@ -616,19 +647,33 @@
"heading": "Custom CSS",
"option_label": "Custom CSS enabled"
},
+ "troubleshooting_mode": {
+ "button": "Troubleshooting mode",
+ "dialog_title": "Enable troubleshooting mode",
+ "dialog_description": "This will temporarily disable all customizations to help diagnose issues:",
+ "dialog_item_plugins": "All plugins",
+ "dialog_item_css": "Custom CSS",
+ "dialog_item_js": "Custom JavaScript",
+ "dialog_item_locales": "Custom locales",
+ "dialog_log_level": "Log level will be set to Debug for detailed diagnostics.",
+ "dialog_reload_note": "The page will reload automatically.",
+ "enable": "Enable & Reload",
+ "overlay_message": "Troubleshooting mode is active - all customizations are disabled",
+ "exit": "Exit"
+ },
"custom_javascript": {
- "description": "Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom Javascript and future releases of Stash.",
- "heading": "Custom Javascript",
- "option_label": "Custom Javascript enabled"
+ "description": "Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom JavaScript and future releases of Stash.",
+ "heading": "Custom JavaScript",
+ "option_label": "Custom JavaScript enabled"
},
"custom_locales": {
"description": "Override individual locale strings. See https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for the master list. Page must be reloaded for changes to take effect.",
- "heading": "Custom localisation",
+ "heading": "Custom Localisation",
"option_label": "Custom localisation enabled"
},
"custom_title": {
"description": "Custom text to append to the page title. If empty, defaults to 'Stash'.",
- "heading": "Custom Title"
+ "heading": "Custom title"
},
"delete_options": {
"description": "Default settings when deleting images, galleries, and scenes.",
@@ -640,14 +685,14 @@
},
"desktop_integration": {
"desktop_integration": "Desktop Integration",
- "notifications_enabled": "Enable Notifications",
- "send_desktop_notifications_for_events": "Send desktop notifications for events",
- "skip_opening_browser": "Skip Opening Browser",
- "skip_opening_browser_on_startup": "Skip auto-opening browser during startup"
+ "notifications_enabled": "Enable notifications",
+ "send_desktop_notifications_for_events": "Send desktop notifications for events.",
+ "skip_opening_browser": "Skip opening browser",
+ "skip_opening_browser_on_startup": "Skip auto-opening browser during startup."
},
"detail": {
"compact_expanded_details": {
- "description": "When enabled, this option will present expanded details while maintaining a compact presentation",
+ "description": "When enabled, this option will present expanded details while maintaining a compact presentation.",
"heading": "Compact expanded details"
},
"enable_background_image": {
@@ -656,13 +701,13 @@
},
"heading": "Detail Page",
"show_all_details": {
- "description": "When enabled, all content details will be shown by default and each detail item will fit under a single column",
+ "description": "When enabled, all content details will be shown by default and each detail item will fit under a single column.",
"heading": "Show all details"
}
},
"editing": {
"disable_dropdown_create": {
- "description": "Remove the ability to create new objects from the dropdown selectors",
+ "description": "Remove the ability to create new objects from the dropdown selectors.",
"heading": "Disable dropdown create"
},
"heading": "Editing",
@@ -680,7 +725,7 @@
}
},
"type": {
- "label": "Rating System Type",
+ "label": "Rating system type",
"options": {
"decimal": "Decimal",
"stars": "Stars"
@@ -690,7 +735,7 @@
},
"funscript_offset": {
"description": "Time offset in milliseconds for interactive scripts playback.",
- "heading": "Funscript Offset (ms)"
+ "heading": "Funscript offset (ms)"
},
"handy_connection": {
"connect": "Connect",
@@ -703,8 +748,8 @@
"sync": "Sync"
},
"handy_connection_key": {
- "description": "Handy connection key to use for interactive scenes. Setting this key will allow Stash to share your current scene information with handyfeeling.com",
- "heading": "Handy Connection Key"
+ "description": "Handy connection key to use for interactive scenes. Setting this key will allow Stash to share your current scene information with handyfeeling.com.",
+ "heading": "Handy connection key"
},
"image_lightbox": {
"heading": "Image Lightbox"
@@ -718,11 +763,11 @@
"heading": "Images",
"options": {
"create_image_clips_from_videos": {
- "description": "When a library has Videos disabled, Video Files (files ending with Video Extension) will be scanned as Image Clip.",
- "heading": "Scan Video Extensions as Image Clip"
+ "description": "When a library has Videos disabled, video files (see Video extensions) will be scanned as image clips.",
+ "heading": "Scan video extensions as image clips"
},
"write_image_thumbnails": {
- "description": "Write image thumbnails to disk when generated on-the-fly",
+ "description": "Write image thumbnails to disk when generated on-the-fly.",
"heading": "Write image thumbnails"
}
}
@@ -732,31 +777,31 @@
"heading": "Language"
},
"max_loop_duration": {
- "description": "Maximum scene duration where scene player will loop the video - 0 to disable",
+ "description": "Maximum scene duration where scene player will loop the video. Set 0 to disable.",
"heading": "Maximum loop duration"
},
"menu_items": {
- "description": "Show or hide different types of content on the navigation bar",
- "heading": "Menu Items"
+ "description": "Show or hide different types of content on the navigation bar.",
+ "heading": "Menu items"
},
"minimum_play_percent": {
"description": "The percentage of time in which a scene must be played before its play count is incremented.",
- "heading": "Minimum Play Percent"
+ "heading": "Minimum play percent"
},
"performers": {
"options": {
"image_location": {
- "description": "Custom path for default performer images. Leave empty to use in-built defaults",
- "heading": "Custom Performer Image Path"
+ "description": "Custom path for default performer images. Leave empty to use built-in defaults.",
+ "heading": "Custom performer image path"
}
}
},
"preview_type": {
"description": "The default option is video (mp4) previews. For less CPU usage when browsing, you can use the animated image (webp) previews. However they must be generated in addition to the video previews and are larger files.",
- "heading": "Preview Type",
+ "heading": "Preview type",
"options": {
- "animated": "Animated Image",
- "static": "Static Image",
+ "animated": "Animated image",
+ "static": "Static image",
"video": "Video"
}
},
@@ -772,14 +817,14 @@
"always_start_from_beginning": "Always start video from beginning",
"auto_start_video": "Auto-start video",
"auto_start_video_on_play_selected": {
- "description": "Auto-start scene videos when playing from queue, or playing selected or random from Scenes page",
+ "description": "Auto-start scene videos when playing from queue, or playing selected or random from Scenes page.",
"heading": "Auto-start video when playing selected"
},
"continue_playlist_default": {
- "description": "Play next scene in queue when video finishes",
+ "description": "Play next scene in queue when video finishes.",
"heading": "Continue playlist by default"
},
- "disable_mobile_media_auto_rotate": "Disable auto-rotate of fullscreen media on Mobile",
+ "disable_mobile_media_auto_rotate": "Disable auto-rotate of fullscreen media on mobile",
"enable_chromecast": "Enable Chromecast",
"show_ab_loop_controls": "Show AB Loop plugin controls",
"show_open_external": "Show 'Open In External Player' button",
@@ -788,7 +833,7 @@
"track_activity": "Enable Scene Play history",
"vr_tag": {
"description": "The VR button will only be displayed for scenes with this tag.",
- "heading": "VR Tag"
+ "heading": "VR tag"
}
}
},
@@ -805,27 +850,27 @@
},
"sfw_mode": {
"description": "Enable if using stash to store SFW content. Hides or changes some adult-content-related aspects of the UI.",
- "heading": "SFW Content Mode"
+ "heading": "SFW content mode"
},
"show_tag_card_on_hover": {
- "description": "Show tag card when hovering tag badges",
+ "description": "Show tag card when hovering tag badges.",
"heading": "Tag card tooltips"
},
"slideshow_delay": {
- "description": "Slideshow is available in galleries when in wall view mode",
- "heading": "Slideshow Delay (seconds)"
+ "description": "Slideshow is available in galleries when in wall view mode.",
+ "heading": "Slideshow delay (seconds)"
},
"studio_panel": {
- "heading": "Studio view",
+ "heading": "Studio View",
"options": {
"show_child_studio_content": {
- "description": "In the studio view, display content from the sub-studios as well",
+ "description": "In the studio view, display content from the sub-studios as well.",
"heading": "Display sub-studios content"
}
}
},
"performer_list": {
- "heading": "Performer list",
+ "heading": "Performer List",
"options": {
"show_links_on_grid_card": {
"heading": "Display links on performer grid cards"
@@ -833,17 +878,17 @@
}
},
"tag_panel": {
- "heading": "Tag view",
+ "heading": "Tag View",
"options": {
"show_child_tagged_content": {
- "description": "In the tag view, display content from the subtags as well",
- "heading": "Display subtag content"
+ "description": "In the tag view, display content from the sub-tags as well.",
+ "heading": "Display sub-tag content"
}
}
},
"title": "User Interface",
"use_stash_hosted_funscript": {
- "description": "When enabled, funscripts will be served directly from Stash to your Handy device without using the third party Handy server. Requires that Stash be accessible from your Handy device, and that an API key is generated if stash has credentials configured.",
+ "description": "When enabled, funscripts will be served directly from Stash to your Handy device without using the third party Handy server. Requires that Stash be accessible from your Handy device, and that an API key is generated if Stash has credentials configured.",
"heading": "Serve funscripts directly"
}
}
@@ -952,19 +997,19 @@
"display_mode": {
"fit_horizontally": "Fit horizontally",
"fit_to_screen": "Fit to screen",
- "label": "Display Mode",
+ "label": "Display mode",
"original": "Original"
},
"options": "Options",
"page_header": "Page {page} / {total}",
"reset_zoom_on_nav": "Reset zoom level when changing image",
"scale_up": {
- "description": "Scale smaller images up to fill screen",
+ "description": "Scale smaller images up to fill screen.",
"label": "Scale up to fit"
},
"scroll_mode": {
"description": "Hold shift to temporarily use other mode.",
- "label": "Scroll Mode",
+ "label": "Scroll mode",
"pan_y": "Pan Y",
"zoom": "Zoom"
}
@@ -982,30 +1027,32 @@
"destination": "Reassign to"
},
"scene_gen": {
- "clip_previews": "Image Clip Previews",
+ "clip_previews": "Image clip previews",
"covers": "Scene covers",
"force_transcodes": "Force Transcode generation",
"force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.",
- "image_previews": "Animated Image Previews",
- "image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
- "image_thumbnails": "Image Thumbnails",
+ "image_phash": "Image perceptual hashes",
+ "image_phash_tooltip": "For deduplication and identification",
+ "image_previews": "Animated image previews",
+ "image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
+ "image_thumbnails": "Image thumbnails",
"interactive_heatmap_speed": "Generate heatmaps and speeds for interactive scenes",
- "marker_image_previews": "Marker Animated Image Previews",
- "marker_image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview Type is set to Animated Image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
- "marker_screenshots": "Marker Screenshots",
+ "marker_image_previews": "Marker animated image previews",
+ "marker_image_previews_tooltip": "Also generate animated (webp) previews, only required when Scene/Marker Wall Preview type is set to Animated image. When browsing they use less CPU than the video previews, but are generated in addition to them and are larger files.",
+ "marker_screenshots": "Marker screenshots",
"marker_screenshots_tooltip": "Marker static JPG images",
- "markers": "Marker Previews",
+ "markers": "Marker previews",
"markers_tooltip": "20 second videos which begin at the given timecode.",
- "override_preview_generation_options": "Override Preview Generation Options",
- "override_preview_generation_options_desc": "Override Preview Generation Options for this operation. Defaults are set in System -> Preview Generation.",
+ "override_preview_generation_options": "Override preview generation options",
+ "override_preview_generation_options_desc": "Override preview generation options for this operation. Defaults are set in System -> Preview Generation.",
"overwrite": "Overwrite existing files",
- "phash": "Perceptual hashes",
+ "phash": "Video perceptual hashes",
"phash_tooltip": "For deduplication and scene identification",
"preview_exclude_end_time_desc": "Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
"preview_exclude_end_time_head": "Exclude end time",
"preview_exclude_start_time_desc": "Exclude the first x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
"preview_exclude_start_time_head": "Exclude start time",
- "preview_generation_options": "Preview Generation Options",
+ "preview_generation_options": "Preview generation options",
"preview_options": "Preview Options",
"preview_preset_desc": "The preset regulates size, quality and encoding time of preview generation. Presets beyond “slow” have diminishing returns and are not recommended.",
"preview_preset_head": "Preview encoding preset",
@@ -1013,7 +1060,7 @@
"preview_seg_count_head": "Number of segments in preview",
"preview_seg_duration_desc": "Duration of each preview segment, in seconds.",
"preview_seg_duration_head": "Preview segment duration",
- "sprites": "Scene Scrubber Sprites",
+ "sprites": "Scene scrubber sprites",
"sprites_tooltip": "The set of images displayed below the video player for easy navigation.",
"transcodes": "Transcodes",
"transcodes_tooltip": "MP4 transcodes will be pre-generated for all content; useful for slow CPUs but requires much more disk space",
@@ -1069,7 +1116,10 @@
"select_youngest": "Select the youngest file in the duplicate group",
"title": "Duplicate Scenes"
},
+ "duplicated": "Duplicated",
"duplicated_phash": "Duplicated (pHash)",
+ "duplicated_stash_id": "Duplicated (Stash ID)",
+ "duplicated_title": "Duplicated (Title)",
"duration": "Duration",
"effect_filters": {
"aspect": "Aspect",
@@ -1166,7 +1216,7 @@
"height_cm": "Height (cm)",
"help": "Help",
"history": "History",
- "ignore_auto_tag": "Ignore Auto Tag",
+ "ignore_auto_tag": "Ignore auto tag",
"image": "Image",
"image_count": "Image Count",
"image_index": "Image #",
@@ -1186,6 +1236,7 @@
"last_o_at": "Last O At",
"last_o_at_sfw": "Last Like At",
"last_played_at": "Last Played At",
+ "latest_scene": "Latest Scene",
"library": "Library",
"loading": {
"generic": "Loading…",
@@ -1234,16 +1285,16 @@
"organized": "Organised",
"orientation": "Orientation",
"package_manager": {
- "add_source": "Add Source",
- "check_for_updates": "Check for Updates",
+ "add_source": "Add source",
+ "check_for_updates": "Check for updates",
"confirm_delete_source": "Are you sure you want to delete source {name} ({url})?",
"confirm_uninstall": "Are you sure you want to uninstall {number} packages?",
"description": "Description",
- "edit_source": "Edit Source",
+ "edit_source": "Edit source",
"hide_unselected": "Hide unselected",
"install": "Install",
- "installed_version": "Installed Version",
- "latest_version": "Latest Version",
+ "installed_version": "Installed version",
+ "latest_version": "Latest version",
"no_packages": "No packages found",
"no_sources": "No sources configured",
"no_upgradable": "No upgradable packages found",
@@ -1254,7 +1305,7 @@
"source": {
"local_path": {
"description": "Relative path to store packages for this source. Note that changing this requires the packages to be moved manually.",
- "heading": "Local Path"
+ "heading": "Local path"
},
"name": "Name",
"url": "Source URL"
@@ -1463,6 +1514,7 @@
"welcome_to_stash": "Welcome to Stash"
},
"stash_id": "Stash ID",
+ "stash_id_count": "Stash ID Count",
"stash_id_endpoint": "Stash ID Endpoint URL",
"stash_ids": "Stash IDs",
"stashbox_search": {
diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts
index 8f30e5d17..ae23a48d4 100644
--- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts
+++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts
@@ -12,6 +12,7 @@ import {
import TextUtils from "src/utils/text";
import {
CriterionType,
+ IDuplicationValue,
IHierarchicalLabelValue,
ILabeledId,
INumberValue,
@@ -36,7 +37,8 @@ export type CriterionValue =
| IStashIDValue
| IDateValue
| ITimestampValue
- | IPhashDistanceValue;
+ | IPhashDistanceValue
+ | IDuplicationValue;
export interface ISavedCriterion {
modifier: CriterionModifier;
diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts
index 58e3535a6..512616f3c 100644
--- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts
+++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts
@@ -58,7 +58,8 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption(
"weight",
"measurements",
"fake_tits",
- "career_length",
+ "career_start",
+ "career_end",
"tattoos",
"piercings",
"aliases",
diff --git a/ui/v2.5/src/models/list-filter/criteria/phash.ts b/ui/v2.5/src/models/list-filter/criteria/phash.ts
index 0cbfa155e..e79b0a447 100644
--- a/ui/v2.5/src/models/list-filter/criteria/phash.ts
+++ b/ui/v2.5/src/models/list-filter/criteria/phash.ts
@@ -1,15 +1,28 @@
import {
CriterionModifier,
PhashDistanceCriterionInput,
- PHashDuplicationCriterionInput,
+ DuplicationCriterionInput,
} from "src/core/generated-graphql";
-import { IPhashDistanceValue } from "../types";
-import {
- BooleanCriterionOption,
- ModifierCriterion,
- ModifierCriterionOption,
- StringCriterion,
-} from "./criterion";
+import { IDuplicationValue, IPhashDistanceValue } from "../types";
+import { ModifierCriterion, ModifierCriterionOption } from "./criterion";
+import { IntlShape } from "react-intl";
+
+// Shared mapping of duplication field IDs to their i18n message IDs
+export const DUPLICATION_FIELD_MESSAGE_IDS = {
+ phash: "media_info.phash",
+ stash_id: "stash_id",
+ title: "title",
+ url: "url",
+} as const;
+
+export type DuplicationFieldId = keyof typeof DUPLICATION_FIELD_MESSAGE_IDS;
+
+export const DUPLICATION_FIELD_IDS: DuplicationFieldId[] = [
+ "phash",
+ "stash_id",
+ "title",
+ "url",
+];
export const PhashCriterionOption = new ModifierCriterionOption({
messageID: "media_info.phash",
@@ -55,20 +68,97 @@ export class PhashCriterion extends ModifierCriterion {
}
}
-export const DuplicatedCriterionOption = new BooleanCriterionOption(
- "duplicated_phash",
- "duplicated",
- () => new DuplicatedCriterion()
-);
+export const DuplicatedCriterionOption = new ModifierCriterionOption({
+ messageID: "duplicated",
+ type: "duplicated",
+ modifierOptions: [], // No modifiers for this filter
+ defaultModifier: CriterionModifier.Equals,
+ makeCriterion: () => new DuplicatedCriterion(),
+});
-export class DuplicatedCriterion extends StringCriterion {
+export class DuplicatedCriterion extends ModifierCriterion {
constructor() {
- super(DuplicatedCriterionOption);
+ super(DuplicatedCriterionOption, {});
}
- public toCriterionInput(): PHashDuplicationCriterionInput {
+ public cloneValues() {
+ this.value = { ...this.value };
+ }
+
+ // Override getLabel to provide custom formatting for duplication fields
+ public getLabel(intl: IntlShape): string {
+ const parts: string[] = [];
+ const trueLabel = intl.formatMessage({ id: "true" });
+ const falseLabel = intl.formatMessage({ id: "false" });
+
+ for (const fieldId of DUPLICATION_FIELD_IDS) {
+ const fieldValue = this.value[fieldId];
+ if (fieldValue !== undefined) {
+ const label = intl.formatMessage({
+ id: DUPLICATION_FIELD_MESSAGE_IDS[fieldId],
+ });
+ parts.push(`${label}: ${fieldValue ? trueLabel : falseLabel}`);
+ }
+ }
+
+ // Handle legacy duplicated field
+ if (parts.length === 0 && this.value.duplicated !== undefined) {
+ const label = intl.formatMessage({ id: "duplicated_phash" });
+ return `${label}: ${this.value.duplicated ? trueLabel : falseLabel}`;
+ }
+
+ if (parts.length === 0) {
+ return intl.formatMessage({ id: "duplicated" });
+ }
+
+ return parts.join(", ");
+ }
+
+ protected getLabelValue(intl: IntlShape): string {
+ // Required by abstract class - returns basic label when getLabel isn't overridden
+ return intl.formatMessage({ id: "duplicated" });
+ }
+
+ protected toCriterionInput(): DuplicationCriterionInput {
return {
- duplicated: this.value === "true",
+ duplicated: this.value.duplicated,
+ distance: this.value.distance,
+ phash: this.value.phash,
+ url: this.value.url,
+ stash_id: this.value.stash_id,
+ title: this.value.title,
};
}
+
+ // Override to handle legacy saved formats
+ public setFromSavedCriterion(criterion: unknown): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const c = criterion as any;
+
+ // Handle various saved formats
+ if (c.value !== undefined) {
+ // New format: { value: { phash: true, ... } }
+ if (typeof c.value === "object") {
+ this.value = c.value as IDuplicationValue;
+ } else if (typeof c.value === "string") {
+ // Legacy format: { value: "true" } - convert to phash
+ this.value = { phash: c.value === "true" };
+ }
+ } else if (typeof c === "object") {
+ // Direct value format
+ this.value = c as IDuplicationValue;
+ }
+
+ if (c.modifier) {
+ this.modifier = c.modifier;
+ }
+ }
+
+ public isValid(): boolean {
+ // Check if any duplication field is set
+ const hasFieldSet = DUPLICATION_FIELD_IDS.some(
+ (fieldId) => this.value[fieldId] !== undefined
+ );
+ return hasFieldSet || this.value.duplicated !== undefined;
+ }
}
diff --git a/ui/v2.5/src/models/list-filter/groups.ts b/ui/v2.5/src/models/list-filter/groups.ts
index 5a263b272..ee0c90d73 100644
--- a/ui/v2.5/src/models/list-filter/groups.ts
+++ b/ui/v2.5/src/models/list-filter/groups.ts
@@ -63,6 +63,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("sub_group_count"),
TagsCriterionOption,
createMandatoryNumberCriterionOption("tag_count"),
+ createMandatoryNumberCriterionOption("scene_count"),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),
];
diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts
index 0b2e06df0..2d3db8265 100644
--- a/ui/v2.5/src/models/list-filter/images.ts
+++ b/ui/v2.5/src/models/list-filter/images.ts
@@ -22,6 +22,7 @@ import {
import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
import { DisplayMode } from "./types";
import { GalleriesCriterionOption } from "./criteria/galleries";
+import { PhashCriterionOption } from "./criteria/phash";
const defaultSortBy = "path";
@@ -47,6 +48,7 @@ const criterionOptions = [
createStringCriterionOption("details"),
createStringCriterionOption("photographer"),
createMandatoryStringCriterionOption("checksum", "media_info.checksum"),
+ PhashCriterionOption,
PathCriterionOption,
GalleriesCriterionOption,
OrganizedCriterionOption,
diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts
index fcc152d01..372dad342 100644
--- a/ui/v2.5/src/models/list-filter/performers.ts
+++ b/ui/v2.5/src/models/list-filter/performers.ts
@@ -31,7 +31,9 @@ const sortByOptions = [
"penis_length",
"play_count",
"last_played_at",
- "career_length",
+ "latest_scene",
+ "career_start",
+ "career_end",
"weight",
"measurements",
"scenes_duration",
@@ -74,6 +76,8 @@ const numberCriteria: CriterionType[] = [
"age",
"weight",
"penis_length",
+ "career_start",
+ "career_end",
];
const stringCriteria: CriterionType[] = [
@@ -85,7 +89,6 @@ const stringCriteria: CriterionType[] = [
"eye_color",
"measurements",
"fake_tits",
- "career_length",
"tattoos",
"piercings",
"aliases",
diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts
index 5fdb6a770..251e2592d 100644
--- a/ui/v2.5/src/models/list-filter/scenes.ts
+++ b/ui/v2.5/src/models/list-filter/scenes.ts
@@ -133,6 +133,7 @@ const criterionOptions = [
GalleriesCriterionOption,
createStringCriterionOption("url"),
StashIDCriterionOption,
+ createMandatoryNumberCriterionOption("stash_id_count"),
InteractiveCriterionOption,
CaptionsCriterionOption,
createMandatoryNumberCriterionOption("interactive_speed"),
diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts
index 02dfae2f6..a38540a47 100644
--- a/ui/v2.5/src/models/list-filter/studios.ts
+++ b/ui/v2.5/src/models/list-filter/studios.ts
@@ -21,6 +21,7 @@ const sortByOptions = [
"random",
"rating",
"scenes_duration",
+ "latest_scene",
]
.map(ListFilterOptions.createSortBy)
.concat([
@@ -52,6 +53,7 @@ const criterionOptions = [
TagsCriterionOption,
RatingCriterionOption,
createBooleanCriterionOption("ignore_auto_tag"),
+ createBooleanCriterionOption("organized"),
createMandatoryNumberCriterionOption("tag_count"),
createMandatoryNumberCriterionOption("scene_count"),
createMandatoryNumberCriterionOption("image_count"),
diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts
index 83ebaa010..7fe334c4c 100644
--- a/ui/v2.5/src/models/list-filter/types.ts
+++ b/ui/v2.5/src/models/list-filter/types.ts
@@ -47,9 +47,15 @@ export interface IRangeValue {
export type INumberValue = IRangeValue;
export type IDateValue = IRangeValue;
export type ITimestampValue = IRangeValue;
-export interface IPHashDuplicationValue {
- duplicated: boolean;
- distance?: number; // currently not implemented
+export interface IDuplicationValue {
+ // Deprecated: Use phash field instead. Kept for backwards compatibility.
+ duplicated?: boolean;
+ // Currently not implemented. Intended for phash distance matching.
+ distance?: number;
+ phash?: boolean;
+ url?: boolean;
+ stash_id?: boolean;
+ title?: boolean;
}
export interface IStashIDValue {
@@ -160,6 +166,8 @@ export type CriterionType =
| "penis_length"
| "circumcised"
| "career_length"
+ | "career_start"
+ | "career_end"
| "tattoos"
| "piercings"
| "aliases"
@@ -200,6 +208,7 @@ export type CriterionType =
| "ignore_auto_tag"
| "file_count"
| "stash_id_endpoint"
+ | "stash_id_count"
| "date"
| "created_at"
| "updated_at"
diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts
index 1aae25129..dd881c0b4 100644
--- a/ui/v2.5/src/pluginApi.d.ts
+++ b/ui/v2.5/src/pluginApi.d.ts
@@ -666,6 +666,8 @@ declare namespace PluginApi {
DetailImage: React.FC;
ExternalLinkButtons: React.FC;
ExternalLinksButton: React.FC;
+ FilteredGalleryList: React.FC;
+ FilteredSceneList: React.FC;
FolderSelect: React.FC;
FrontPage: React.FC;
GalleryCard: React.FC;
diff --git a/ui/v2.5/src/plugins.tsx b/ui/v2.5/src/plugins.tsx
index 41577a92c..00ffb9ca4 100644
--- a/ui/v2.5/src/plugins.tsx
+++ b/ui/v2.5/src/plugins.tsx
@@ -59,7 +59,8 @@ function sortPlugins(plugins: PluginList) {
// load all plugins and their dependencies
// returns true when all plugins are loaded, regardess of success or failure
-function useLoadPlugins() {
+// if disableCustomizations is true, skip loading plugins entirely
+function useLoadPlugins(disableCustomizations?: boolean) {
const {
data: plugins,
loading: pluginsLoading,
@@ -74,6 +75,12 @@ function useLoadPlugins() {
}, [plugins?.plugins, pluginsLoading, pluginsError]);
const pluginJavascripts = useMemoOnce(() => {
+ // Skip loading plugin JS if customizations are disabled.
+ // Note: We check inside useMemoOnce rather than early-returning from useLoadPlugins
+ // to comply with React's rules of hooks - hooks must be called unconditionally.
+ if (disableCustomizations) {
+ return [[], true];
+ }
return [
uniq(
sortedPlugins
@@ -83,9 +90,12 @@ function useLoadPlugins() {
),
!!sortedPlugins && !pluginsLoading && !pluginsError,
];
- }, [sortedPlugins, pluginsLoading, pluginsError]);
+ }, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]);
const pluginCSS = useMemoOnce(() => {
+ if (disableCustomizations) {
+ return [[], true];
+ }
return [
uniq(
sortedPlugins
@@ -95,7 +105,7 @@ function useLoadPlugins() {
),
!!sortedPlugins && !pluginsLoading && !pluginsError,
];
- }, [sortedPlugins, pluginsLoading, pluginsError]);
+ }, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]);
const pluginJavascriptLoaded = useScript(
pluginJavascripts ?? [],
@@ -109,11 +119,15 @@ function useLoadPlugins() {
};
}
-export const PluginsLoader: React.FC> = ({
- children,
-}) => {
+interface IPluginsLoaderProps {
+ disableCustomizations?: boolean;
+}
+
+export const PluginsLoader: React.FC<
+ React.PropsWithChildren
+> = ({ disableCustomizations, children }) => {
const Toast = useToast();
- const { loading: loaded, error } = useLoadPlugins();
+ const { loading: loaded, error } = useLoadPlugins(disableCustomizations);
useEffect(() => {
if (error) {
diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts
index 581d079c7..17d9dfe6b 100644
--- a/ui/v2.5/src/utils/navigation.ts
+++ b/ui/v2.5/src/utils/navigation.ts
@@ -342,6 +342,15 @@ const makeScenesPHashMatchUrl = (phash: GQL.Maybe | undefined) => {
return `/scenes?${filter.makeQueryParameters()}`;
};
+const makeImagesPHashMatchUrl = (phash: GQL.Maybe | undefined) => {
+ if (!phash) return "#";
+ const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);
+ const criterion = new PhashCriterion();
+ criterion.value = { value: phash };
+ filter.criteria.push(criterion);
+ return `/images?${filter.makeQueryParameters()}`;
+};
+
const makeGalleryImagesUrl = (
gallery: Partial,
extraCriteria?: ModifierCriterion[]
@@ -493,6 +502,7 @@ const NavUtils = {
makeTagGroupsUrl,
makeScenesPHashMatchUrl,
makeSceneMarkerUrl,
+ makeImagesPHashMatchUrl,
makeGroupScenesUrl,
makeChildStudiosUrl,
makeGalleryImagesUrl,
diff --git a/ui/v2.5/src/utils/yup.ts b/ui/v2.5/src/utils/yup.ts
index 5ae8123df..a9c4f69e1 100644
--- a/ui/v2.5/src/utils/yup.ts
+++ b/ui/v2.5/src/utils/yup.ts
@@ -92,45 +92,6 @@ export function yupUniqueStringList(intl: IntlShape) {
});
}
-export function yupUniqueAliases(intl: IntlShape, nameField: string) {
- return yupRequiredStringArray(intl)
- .defined()
- .test({
- name: "unique",
- test(value) {
- const aliases = [this.parent[nameField].toLowerCase()];
- const dupes: number[] = [];
- for (let i = 0; i < value.length; i++) {
- const s = value[i].toLowerCase();
- if (aliases.includes(s)) {
- dupes.push(i);
- } else {
- aliases.push(s);
- }
- }
- if (dupes.length === 0) return true;
-
- const msg = yup.ValidationError.formatError(
- intl.formatMessage({ id: "validation.unique" }),
- {
- label: this.schema.spec.label,
- path: this.path,
- }
- );
- const errors = dupes.map(
- (i) =>
- new yup.ValidationError(
- msg,
- value[i],
- `${this.path}["${i}"]`,
- "unique"
- )
- );
- return new yup.ValidationError(errors, value, this.path, "unique");
- },
- });
-}
-
export function yupDateString(intl: IntlShape) {
return yup
.string()