From f4733f4e8dd3721cb8a529871b095c7b3d10bb8d Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Fri, 28 Nov 2025 21:49:24 -0800 Subject: [PATCH] duplicate phash --- .../List/Filters/DuplicateFilter.tsx | 118 ++++++++++++++++++ ui/v2.5/src/components/List/styles.scss | 18 +++ ui/v2.5/src/components/Scenes/SceneList.tsx | 7 ++ ui/v2.5/src/locales/en-GB.json | 1 + 4 files changed, 144 insertions(+) create mode 100644 ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx diff --git a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx new file mode 100644 index 000000000..f80cb7219 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx @@ -0,0 +1,118 @@ +import React, { useMemo, useState } from "react"; +import { useIntl } from "react-intl"; +import { BooleanCriterion } from "src/models/list-filter/criteria/criterion"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Option, SidebarListFilter } from "./SidebarListFilter"; +import { DuplicatedCriterionOption } from "src/models/list-filter/criteria/phash"; + +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" }); + const phashLabel = intl.formatMessage({ id: "media_info.phash" }); + + const criteria = filter.criteriaFor( + DuplicatedCriterionOption.type + ) as BooleanCriterion[]; + const criterion = criteria.length > 0 ? criteria[0] : null; + + // The main duplicate type option + const phashOption = useMemo( + () => ({ + id: "phash", + label: phashLabel, + }), + [phashLabel] + ); + + // Determine if pHash is selected (has a true/false value) + const phashSelected = criterion !== null; + + // Selected shows "pHash: True" or "pHash: False" when a value is set + const selected: Option[] = useMemo(() => { + if (!criterion) return []; + + const valueLabel = criterion.value === "true" ? trueLabel : falseLabel; + return [ + { + id: "phash", + label: `${phashLabel}: ${valueLabel}`, + }, + ]; + }, [criterion, phashLabel, trueLabel, falseLabel]); + + // Available options - show pHash if not selected + const options: Option[] = useMemo(() => { + if (phashSelected) return []; + return [phashOption]; + }, [phashSelected, phashOption]); + + function onSelect(item: Option) { + if (item.id === "phash") { + // Expand to show True/False options + setExpandedType("phash"); + } + } + + function onUnselect() { + setFilter(filter.removeCriterion(DuplicatedCriterionOption.type)); + setExpandedType(null); + } + + function onSelectValue(value: "true" | "false") { + const newCriterion = criterion + ? criterion.clone() + : DuplicatedCriterionOption.makeCriterion(); + newCriterion.value = value; + setFilter( + filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion]) + ); + setExpandedType(null); + } + + // Sub-options shown when pHash is clicked + const subOptions = + expandedType === "phash" ? ( +
+
onSelectValue("true")} + > + {trueLabel} +
+
onSelectValue("false")} + > + {falseLabel} +
+
+ ) : null; + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 60cf0c52f..cd8c287af 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); } diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index dc3ea76c6..4ac018660 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -60,6 +60,7 @@ import { DurationCriterionOption, 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 { @@ -351,6 +352,12 @@ const SidebarContent: React.FC<{ setFilter={setFilter} sectionID="organized" /> + } + filter={filter} + setFilter={setFilter} + sectionID="duplicated" + /> } option={PerformerAgeCriterionOption} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 37ef0d12b..13861215a 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1059,6 +1059,7 @@ "select_youngest": "Select the youngest file in the duplicate group", "title": "Duplicate Scenes" }, + "duplicated": "Duplicated", "duplicated_phash": "Duplicated (pHash)", "duration": "Duration", "effect_filters": {