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 1/7] 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": { From 280b40c466fddf58abf6ce4b7c7268d10ec34212 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Fri, 28 Nov 2025 22:46:05 -0800 Subject: [PATCH 2/7] StudioID+Title --- graphql/schema/types/filters.graphql | 12 ++ pkg/models/scene.go | 12 ++ pkg/sqlite/scene_filter.go | 34 +++ .../List/Filters/DuplicateFilter.tsx | 199 +++++++++++------- ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/stash-ids.ts | 21 ++ .../src/models/list-filter/criteria/title.ts | 20 ++ ui/v2.5/src/models/list-filter/scenes.ts | 8 +- 8 files changed, 232 insertions(+), 76 deletions(-) create mode 100644 ui/v2.5/src/models/list-filter/criteria/title.ts diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 4eb91aa77..7ebc6ec72 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -81,6 +81,14 @@ input PHashDuplicationCriterionInput { distance: Int } +input StashIDDuplicationCriterionInput { + duplicated: Boolean +} + +input TitleDuplicationCriterionInput { + duplicated: Boolean +} + input StashIDCriterionInput { """ If present, this value is treated as a predicate. @@ -250,6 +258,10 @@ input SceneFilterType { o_counter: IntCriterionInput "Filter Scenes that have an exact phash match available" duplicated: PHashDuplicationCriterionInput + "Filter Scenes that have the same stash_id" + duplicated_stash_id: StashIDDuplicationCriterionInput + "Filter Scenes that have the same title" + duplicated_title: TitleDuplicationCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput "Filter by orientation" diff --git a/pkg/models/scene.go b/pkg/models/scene.go index f0a863bf7..5aac3030d 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -8,6 +8,14 @@ type PHashDuplicationCriterionInput struct { Distance *int `json:"distance"` } +type StashIDDuplicationCriterionInput struct { + Duplicated *bool `json:"duplicated"` +} + +type TitleDuplicationCriterionInput struct { + Duplicated *bool `json:"duplicated"` +} + type SceneFilterType struct { OperatorFilter[SceneFilterType] ID *IntCriterionInput `json:"id"` @@ -35,6 +43,10 @@ type SceneFilterType struct { OCounter *IntCriterionInput `json:"o_counter"` // Filter Scenes that have an exact phash match available Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` + // Filter Scenes that have the same stash_id + DuplicatedStashID *StashIDDuplicationCriterionInput `json:"duplicated_stash_id"` + // Filter Scenes that have the same title + DuplicatedTitle *TitleDuplicationCriterionInput `json:"duplicated_title"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` // Filter by orientation diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index fad300248..5c5b818ae 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -156,6 +156,8 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), qb.performerAgeCriterionHandler(sceneFilter.PerformerAge), qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable), + qb.stashIDDuplicatedCriterionHandler(sceneFilter.DuplicatedStashID), + qb.titleDuplicatedCriterionHandler(sceneFilter.DuplicatedTitle), &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, @@ -297,6 +299,38 @@ func (qb *sceneFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter * } } +func (qb *sceneFilterHandler) stashIDDuplicatedCriterionHandler(duplicatedFilter *models.StashIDDuplicationCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if duplicatedFilter != nil && duplicatedFilter.Duplicated != nil { + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + // Find stash_ids that appear on more than one scene + f.addInnerJoin("(SELECT scene_id FROM scene_stash_ids INNER JOIN (SELECT stash_id FROM scene_stash_ids GROUP BY stash_id HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_stash_ids.stash_id = dupes.stash_id)", "scsi", "scenes.id = scsi.scene_id") + } + } +} + +func (qb *sceneFilterHandler) titleDuplicatedCriterionHandler(duplicatedFilter *models.TitleDuplicationCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if duplicatedFilter != nil && duplicatedFilter.Duplicated != nil { + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + // Find titles that appear on more than one scene (excluding empty titles) + f.addInnerJoin("(SELECT id FROM scenes WHERE title != '' AND title IS NOT NULL AND title IN (SELECT title FROM scenes WHERE title != '' AND title IS NOT NULL GROUP BY title HAVING COUNT(*) "+v+" 1))", "sctitle", "scenes.id = sctitle.id") + } + } +} + func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if codec != nil { diff --git a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx index f80cb7219..fa4c4cc66 100644 --- a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx @@ -2,8 +2,23 @@ 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 { Option, SelectedList } from "./SidebarListFilter"; import { DuplicatedCriterionOption } from "src/models/list-filter/criteria/phash"; +import { DuplicatedStashIDCriterionOption } from "src/models/list-filter/criteria/stash-ids"; +import { DuplicatedTitleCriterionOption } from "src/models/list-filter/criteria/title"; +import { SidebarSection } from "src/components/Shared/Sidebar"; +import { Icon } from "src/components/Shared/Icon"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { keyboardClickHandler } from "src/utils/keyboard"; + +// Mapping of duplicate type IDs to their criterion options +const DUPLICATE_TYPES = { + phash: DuplicatedCriterionOption, + stash_id: DuplicatedStashIDCriterionOption, + title: DuplicatedTitleCriterionOption, +} as const; + +type DuplicateTypeId = keyof typeof DUPLICATE_TYPES; interface ISidebarDuplicateFilterProps { title?: React.ReactNode; @@ -24,95 +39,129 @@ export const SidebarDuplicateFilter: React.FC = ({ const trueLabel = intl.formatMessage({ id: "true" }); const falseLabel = intl.formatMessage({ id: "false" }); const phashLabel = intl.formatMessage({ id: "media_info.phash" }); + const stashIdLabel = intl.formatMessage({ id: "stash_id" }); + const titleLabel = intl.formatMessage({ id: "title" }); - const criteria = filter.criteriaFor( - DuplicatedCriterionOption.type - ) as BooleanCriterion[]; - const criterion = criteria.length > 0 ? criteria[0] : null; + // Labels for each duplicate type + const labels: Record = { + phash: phashLabel, + stash_id: stashIdLabel, + title: titleLabel, + }; - // The main duplicate type option - const phashOption = useMemo( - () => ({ - id: "phash", - label: phashLabel, - }), - [phashLabel] - ); + // Get criterion for a given type + function getCriterion(typeId: DuplicateTypeId): BooleanCriterion | null { + const criteria = filter.criteriaFor( + DUPLICATE_TYPES[typeId].type + ) as BooleanCriterion[]; + return criteria.length > 0 ? criteria[0] : null; + } - // 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 + // Build selected items list const selected: Option[] = useMemo(() => { - if (!criterion) return []; + const result: Option[] = []; - 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"); + for (const typeId of Object.keys(DUPLICATE_TYPES) as DuplicateTypeId[]) { + const criterion = getCriterion(typeId); + if (criterion) { + const valueLabel = criterion.value === "true" ? trueLabel : falseLabel; + result.push({ + id: typeId, + label: `${labels[typeId]}: ${valueLabel}`, + }); + } } + + return result; + }, [filter, trueLabel, falseLabel, labels]); + + // Available options - show options that aren't already selected + const options = useMemo(() => { + const result: { id: DuplicateTypeId; label: string }[] = []; + + for (const typeId of Object.keys(DUPLICATE_TYPES) as DuplicateTypeId[]) { + if (!getCriterion(typeId)) { + result.push({ id: typeId, label: labels[typeId] }); + } + } + + return result; + }, [filter, labels]); + + function onToggleExpand(id: string) { + setExpandedType(expandedType === id ? null : id); } - function onUnselect() { - setFilter(filter.removeCriterion(DuplicatedCriterionOption.type)); + function onUnselect(item: Option) { + const typeId = item.id as DuplicateTypeId; + const criterionOption = DUPLICATE_TYPES[typeId]; + if (criterionOption) { + setFilter(filter.removeCriterion(criterionOption.type)); + } setExpandedType(null); } - function onSelectValue(value: "true" | "false") { - const newCriterion = criterion - ? criterion.clone() - : DuplicatedCriterionOption.makeCriterion(); + function onSelectValue(typeId: string, value: "true" | "false") { + const criterionOption = DUPLICATE_TYPES[typeId as DuplicateTypeId]; + if (!criterionOption) return; + + const existingCriterion = getCriterion(typeId as DuplicateTypeId); + const newCriterion = existingCriterion + ? existingCriterion.clone() + : criterionOption.makeCriterion(); newCriterion.value = value; - setFilter( - filter.replaceCriteria(DuplicatedCriterionOption.type, [newCriterion]) - ); + setFilter(filter.replaceCriteria(criterionOption.type, [newCriterion])); setExpandedType(null); } - // Sub-options shown when pHash is clicked - const subOptions = - expandedType === "phash" ? ( -
-
onSelectValue("true")} - > - {trueLabel} -
-
onSelectValue("false")} - > - {falseLabel} -
-
- ) : null; - return ( - + outsideCollapse={ + onUnselect(i)} + /> + } + > +
+ +
+ ); -}; +}; \ No newline at end of file diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 13861215a..04464584b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1061,6 +1061,8 @@ }, "duplicated": "Duplicated", "duplicated_phash": "Duplicated (pHash)", + "duplicated_stash_id": "Duplicated (Stash ID)", + "duplicated_title": "Duplicated (Title)", "duration": "Duration", "effect_filters": { "aspect": "Aspect", diff --git a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts index 1800930fb..24b68366b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts +++ b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts @@ -3,12 +3,15 @@ import { IntlShape } from "react-intl"; import { CriterionModifier, StashIdCriterionInput, + StashIdDuplicationCriterionInput, } from "src/core/generated-graphql"; import { IStashIDValue } from "../types"; import { + BooleanCriterionOption, ISavedCriterion, ModifierCriterion, ModifierCriterionOption, + StringCriterion, } from "./criterion"; export const StashIDCriterionOption = new ModifierCriterionOption({ @@ -145,3 +148,21 @@ export class StashIDCriterion extends ModifierCriterion { ); } } + +export const DuplicatedStashIDCriterionOption = new BooleanCriterionOption( + "duplicated_stash_id", + "duplicated_stash_id", + () => new DuplicatedStashIDCriterion() +); + +export class DuplicatedStashIDCriterion extends StringCriterion { + constructor() { + super(DuplicatedStashIDCriterionOption); + } + + public toCriterionInput(): StashIdDuplicationCriterionInput { + return { + duplicated: this.value === "true", + }; + } +} diff --git a/ui/v2.5/src/models/list-filter/criteria/title.ts b/ui/v2.5/src/models/list-filter/criteria/title.ts new file mode 100644 index 000000000..f51ed3911 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/title.ts @@ -0,0 +1,20 @@ +import { TitleDuplicationCriterionInput } from "src/core/generated-graphql"; +import { BooleanCriterionOption, StringCriterion } from "./criterion"; + +export const DuplicatedTitleCriterionOption = new BooleanCriterionOption( + "duplicated_title", + "duplicated_title", + () => new DuplicatedTitleCriterion() +); + +export class DuplicatedTitleCriterion extends StringCriterion { + constructor() { + super(DuplicatedTitleCriterionOption); + } + + public toCriterionInput(): TitleDuplicationCriterionInput { + return { + duplicated: this.value === "true", + }; + } +} \ No newline at end of file diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index cf2791567..0bdaf238d 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -31,7 +31,11 @@ import { } from "./criteria/phash"; import { PerformerFavoriteCriterionOption } from "./criteria/favorite"; import { CaptionsCriterionOption } from "./criteria/captions"; -import { StashIDCriterionOption } from "./criteria/stash-ids"; +import { + DuplicatedStashIDCriterionOption, + StashIDCriterionOption, +} from "./criteria/stash-ids"; +import { DuplicatedTitleCriterionOption } from "./criteria/title"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { OrientationCriterionOption } from "./criteria/orientation"; @@ -100,6 +104,8 @@ const criterionOptions = [ createStringCriterionOption("checksum", "media_info.checksum"), PhashCriterionOption, DuplicatedCriterionOption, + DuplicatedStashIDCriterionOption, + DuplicatedTitleCriterionOption, OrganizedCriterionOption, RatingCriterionOption, createMandatoryNumberCriterionOption("o_counter", "o_count", { From d5589db8d361d7e80c4de887f2c954e034c2e147 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Fri, 28 Nov 2025 22:59:32 -0800 Subject: [PATCH 3/7] Lint --- .../List/Filters/DuplicateFilter.tsx | 46 +++++++++++-------- .../src/models/list-filter/criteria/title.ts | 2 +- ui/v2.5/src/models/list-filter/types.ts | 2 + 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx index fa4c4cc66..da779f227 100644 --- a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react"; +import React, { useCallback, 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"; @@ -43,19 +43,25 @@ export const SidebarDuplicateFilter: React.FC = ({ const titleLabel = intl.formatMessage({ id: "title" }); // Labels for each duplicate type - const labels: Record = { - phash: phashLabel, - stash_id: stashIdLabel, - title: titleLabel, - }; + const labels: Record = useMemo( + () => ({ + phash: phashLabel, + stash_id: stashIdLabel, + title: titleLabel, + }), + [phashLabel, stashIdLabel, titleLabel] + ); // Get criterion for a given type - function getCriterion(typeId: DuplicateTypeId): BooleanCriterion | null { - const criteria = filter.criteriaFor( - DUPLICATE_TYPES[typeId].type - ) as BooleanCriterion[]; - return criteria.length > 0 ? criteria[0] : null; - } + const getCriterion = useCallback( + (typeId: DuplicateTypeId): BooleanCriterion | null => { + const criteria = filter.criteriaFor( + DUPLICATE_TYPES[typeId].type + ) as BooleanCriterion[]; + return criteria.length > 0 ? criteria[0] : null; + }, + [filter] + ); // Build selected items list const selected: Option[] = useMemo(() => { @@ -73,7 +79,7 @@ export const SidebarDuplicateFilter: React.FC = ({ } return result; - }, [filter, trueLabel, falseLabel, labels]); + }, [getCriterion, trueLabel, falseLabel, labels]); // Available options - show options that aren't already selected const options = useMemo(() => { @@ -86,7 +92,7 @@ export const SidebarDuplicateFilter: React.FC = ({ } return result; - }, [filter, labels]); + }, [getCriterion, labels]); function onToggleExpand(id: string) { setExpandedType(expandedType === id ? null : id); @@ -120,10 +126,7 @@ export const SidebarDuplicateFilter: React.FC = ({ text={title} sectionID={sectionID} outsideCollapse={ - onUnselect(i)} - /> + onUnselect(i)} /> } >
@@ -137,7 +140,10 @@ export const SidebarDuplicateFilter: React.FC = ({ tabIndex={0} >
- + {opt.label}
@@ -164,4 +170,4 @@ export const SidebarDuplicateFilter: React.FC = ({
); -}; \ No newline at end of file +}; diff --git a/ui/v2.5/src/models/list-filter/criteria/title.ts b/ui/v2.5/src/models/list-filter/criteria/title.ts index f51ed3911..46345741e 100644 --- a/ui/v2.5/src/models/list-filter/criteria/title.ts +++ b/ui/v2.5/src/models/list-filter/criteria/title.ts @@ -17,4 +17,4 @@ export class DuplicatedTitleCriterion extends StringCriterion { duplicated: this.value === "true", }; } -} \ No newline at end of file +} diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 83ebaa010..9e0101468 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -197,6 +197,8 @@ export type CriterionType = | "favorite" | "performer_age" | "duplicated" + | "duplicated_stash_id" + | "duplicated_title" | "ignore_auto_tag" | "file_count" | "stash_id_endpoint" From 42aebb648bfc651b23b773982e677011d81ae555 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Fri, 28 Nov 2025 23:05:01 -0800 Subject: [PATCH 4/7] Condense --- .../List/Filters/DuplicateFilter.tsx | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx index da779f227..7905dc4ae 100644 --- a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx @@ -27,6 +27,13 @@ interface ISidebarDuplicateFilterProps { sectionID?: string; } +// i18n message IDs for each duplicate type +const DUPLICATE_TYPE_MESSAGE_IDS: Record = { + phash: "media_info.phash", + stash_id: "stash_id", + title: "title", +}; + export const SidebarDuplicateFilter: React.FC = ({ title, filter, @@ -38,18 +45,12 @@ export const SidebarDuplicateFilter: React.FC = ({ const trueLabel = intl.formatMessage({ id: "true" }); const falseLabel = intl.formatMessage({ id: "false" }); - const phashLabel = intl.formatMessage({ id: "media_info.phash" }); - const stashIdLabel = intl.formatMessage({ id: "stash_id" }); - const titleLabel = intl.formatMessage({ id: "title" }); - // Labels for each duplicate type - const labels: Record = useMemo( - () => ({ - phash: phashLabel, - stash_id: stashIdLabel, - title: titleLabel, - }), - [phashLabel, stashIdLabel, titleLabel] + // Get label for a duplicate type + const getLabel = useCallback( + (typeId: DuplicateTypeId) => + intl.formatMessage({ id: DUPLICATE_TYPE_MESSAGE_IDS[typeId] }), + [intl] ); // Get criterion for a given type @@ -73,13 +74,13 @@ export const SidebarDuplicateFilter: React.FC = ({ const valueLabel = criterion.value === "true" ? trueLabel : falseLabel; result.push({ id: typeId, - label: `${labels[typeId]}: ${valueLabel}`, + label: `${getLabel(typeId)}: ${valueLabel}`, }); } } return result; - }, [getCriterion, trueLabel, falseLabel, labels]); + }, [getCriterion, trueLabel, falseLabel, getLabel]); // Available options - show options that aren't already selected const options = useMemo(() => { @@ -87,12 +88,12 @@ export const SidebarDuplicateFilter: React.FC = ({ for (const typeId of Object.keys(DUPLICATE_TYPES) as DuplicateTypeId[]) { if (!getCriterion(typeId)) { - result.push({ id: typeId, label: labels[typeId] }); + result.push({ id: typeId, label: getLabel(typeId) }); } } return result; - }, [getCriterion, labels]); + }, [getCriterion, getLabel]); function onToggleExpand(id: string) { setExpandedType(expandedType === id ? null : id); From c4c5d2d64d86e10c8c6fc7df0733dd6a3fc97a43 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:35:40 -0800 Subject: [PATCH 5/7] sort all urls alphabetically --- pkg/sqlite/gallery.go | 18 ++++++++++++++++-- pkg/sqlite/group.go | 18 ++++++++++++++++-- pkg/sqlite/image.go | 18 ++++++++++++++++-- pkg/sqlite/performer.go | 17 +++++++++++++++-- pkg/sqlite/scene.go | 17 +++++++++++++++-- pkg/sqlite/studio.go | 18 ++++++++++++++++-- pkg/utils/url.go | 24 +++++++++++++++++++++++- 7 files changed, 117 insertions(+), 13 deletions(-) diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 9cfe38b1f..04b7be9a1 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -12,6 +12,7 @@ import ( "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" ) @@ -246,8 +247,10 @@ func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, f } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := galleriesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := galleriesURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -286,7 +289,9 @@ func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Galler } if updatedObject.URLs.Loaded() { - if err := galleriesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := galleriesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } @@ -339,6 +344,15 @@ func (qb *GalleryStore) UpdatePartial(ctx context.Context, id int, partial model if err := galleriesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := galleriesURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := galleriesURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if partial.PerformerIDs != nil { if err := galleriesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index f0f8d6b40..fc3feb7d1 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -14,6 +14,7 @@ import ( "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) const ( @@ -172,8 +173,10 @@ func (qb *GroupStore) Create(ctx context.Context, newObject *models.Group) error } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := groupsURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := groupsURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -219,6 +222,15 @@ func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models. if err := groupsURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := groupsURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := groupsURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if err := qb.tagRelationshipStore.modifyRelationships(ctx, id, partial.TagIDs); err != nil { @@ -245,7 +257,9 @@ func (qb *GroupStore) Update(ctx context.Context, updatedObject *models.Group) e } if updatedObject.URLs.Loaded() { - if err := groupsURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := groupsURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 1588fa415..bdad885e4 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -11,6 +11,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stashapp/stash/pkg/utils" "gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4/zero" @@ -251,8 +252,10 @@ func (qb *ImageStore) Create(ctx context.Context, newObject *models.Image, fileI } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := imagesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := imagesURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -309,6 +312,15 @@ func (qb *ImageStore) UpdatePartial(ctx context.Context, id int, partial models. if err := imagesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := imagesURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := imagesURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if partial.PerformerIDs != nil { if err := imagesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { @@ -339,7 +351,9 @@ func (qb *ImageStore) Update(ctx context.Context, updatedObject *models.Image) e } if updatedObject.URLs.Loaded() { - if err := imagesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := imagesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index c5943b182..821bbabe3 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -269,8 +269,10 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.CreatePe } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := performersURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := performersURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -327,6 +329,15 @@ func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial mod if err := performersURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := performersURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := performersURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if partial.TagIDs != nil { @@ -362,7 +373,9 @@ func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Upda } if updatedObject.URLs.Loaded() { - if err := performersURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := performersURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 40feb5847..f4dd9537e 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -315,8 +315,10 @@ func (qb *SceneStore) Create(ctx context.Context, newObject *models.Scene, fileI } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := scenesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := scenesURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -379,6 +381,15 @@ func (qb *SceneStore) UpdatePartial(ctx context.Context, id int, partial models. if err := scenesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := scenesURLsTableMgr.get(ctx, id) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := scenesURLsTableMgr.replaceJoins(ctx, id, urls); err != nil { + return nil, err + } } if partial.PerformerIDs != nil { if err := scenesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { @@ -423,7 +434,9 @@ func (qb *SceneStore) Update(ctx context.Context, updatedObject *models.Scene) e } if updatedObject.URLs.Loaded() { - if err := scenesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := scenesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 1a05be6f3..149360d98 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -15,6 +15,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/studio" + "github.com/stashapp/stash/pkg/utils" ) const ( @@ -191,8 +192,10 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } if newObject.URLs.Loaded() { + urls := newObject.URLs.List() + utils.SortURLs(urls) const startPos = 0 - if err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := studiosURLsTableMgr.insertJoins(ctx, id, startPos, urls); err != nil { return err } } @@ -241,6 +244,15 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar if err := studiosURLsTableMgr.modifyJoins(ctx, input.ID, input.URLs.Values, input.URLs.Mode); err != nil { return nil, err } + // Re-sort URLs after modification + urls, err := studiosURLsTableMgr.get(ctx, input.ID) + if err != nil { + return nil, err + } + utils.SortURLs(urls) + if err := studiosURLsTableMgr.replaceJoins(ctx, input.ID, urls); err != nil { + return nil, err + } } if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil { @@ -272,7 +284,9 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) } if updatedObject.URLs.Loaded() { - if err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + urls := updatedObject.URLs.List() + utils.SortURLs(urls) + if err := studiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, urls); err != nil { return err } } diff --git a/pkg/utils/url.go b/pkg/utils/url.go index e4d2df237..632b64e56 100644 --- a/pkg/utils/url.go +++ b/pkg/utils/url.go @@ -1,6 +1,10 @@ package utils -import "regexp" +import ( + "regexp" + "sort" + "strings" +) // URLFromHandle adds the site URL to the input if the input is not already a URL // siteURL must not end with a slash @@ -13,3 +17,21 @@ func URLFromHandle(input string, siteURL string) string { return siteURL + "/" + input } + +// urlSortKey extracts the sortable portion of a URL by removing the protocol and www. prefix +func urlSortKey(url string) string { + // Remove http:// or https:// + key := strings.TrimPrefix(url, "https://") + key = strings.TrimPrefix(key, "http://") + // Remove www. prefix + key = strings.TrimPrefix(key, "www.") + return strings.ToLower(key) +} + +// SortURLs sorts a slice of URLs alphabetically by their base URL, +// excluding the protocol (http/https) and www. prefix +func SortURLs(urls []string) { + sort.SliceStable(urls, func(i, j int) bool { + return urlSortKey(urls[i]) < urlSortKey(urls[j]) + }) +} From 6e51ba55310dad1cef7e951bc1ff4397a2ad32f9 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:47:18 -0800 Subject: [PATCH 6/7] update urls test --- pkg/sqlite/performer_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index d5d8ce2fa..d0452d4ba 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -445,7 +445,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { url = "url" twitter = "twitter" instagram = "instagram" - urls = []string{url, twitter, instagram} + urls = []string{instagram, twitter, url} // sorted alphabetically rating = 3 ethnicity = "ethnicity" country = "country" From e057b2899b8fef9fafb779aca35525312cd5e33a Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:10:50 -0800 Subject: [PATCH 7/7] merge sorting + add URLs --- graphql/schema/types/filters.graphql | 6 ++++++ pkg/models/scene.go | 6 ++++++ pkg/sqlite/scene_filter.go | 17 ++++++++++++++++ .../List/Filters/DuplicateFilter.tsx | 3 +++ ui/v2.5/src/locales/en-GB.json | 1 + .../src/models/list-filter/criteria/url.ts | 20 +++++++++++++++++++ ui/v2.5/src/models/list-filter/scenes.ts | 2 ++ ui/v2.5/src/models/list-filter/types.ts | 1 + 8 files changed, 56 insertions(+) create mode 100644 ui/v2.5/src/models/list-filter/criteria/url.ts diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 7ebc6ec72..13fc2ef7f 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -89,6 +89,10 @@ input TitleDuplicationCriterionInput { duplicated: Boolean } +input URLDuplicationCriterionInput { + duplicated: Boolean +} + input StashIDCriterionInput { """ If present, this value is treated as a predicate. @@ -262,6 +266,8 @@ input SceneFilterType { duplicated_stash_id: StashIDDuplicationCriterionInput "Filter Scenes that have the same title" duplicated_title: TitleDuplicationCriterionInput + "Filter Scenes that have the same URL" + duplicated_url: URLDuplicationCriterionInput "Filter by resolution" resolution: ResolutionCriterionInput "Filter by orientation" diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 5aac3030d..364ea2b75 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -16,6 +16,10 @@ type TitleDuplicationCriterionInput struct { Duplicated *bool `json:"duplicated"` } +type URLDuplicationCriterionInput struct { + Duplicated *bool `json:"duplicated"` +} + type SceneFilterType struct { OperatorFilter[SceneFilterType] ID *IntCriterionInput `json:"id"` @@ -47,6 +51,8 @@ type SceneFilterType struct { DuplicatedStashID *StashIDDuplicationCriterionInput `json:"duplicated_stash_id"` // Filter Scenes that have the same title DuplicatedTitle *TitleDuplicationCriterionInput `json:"duplicated_title"` + // Filter Scenes that have the same URL + DuplicatedURL *URLDuplicationCriterionInput `json:"duplicated_url"` // Filter by resolution Resolution *ResolutionCriterionInput `json:"resolution"` // Filter by orientation diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 5c5b818ae..559e2004b 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -158,6 +158,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable), qb.stashIDDuplicatedCriterionHandler(sceneFilter.DuplicatedStashID), qb.titleDuplicatedCriterionHandler(sceneFilter.DuplicatedTitle), + qb.urlDuplicatedCriterionHandler(sceneFilter.DuplicatedURL), &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, @@ -331,6 +332,22 @@ func (qb *sceneFilterHandler) titleDuplicatedCriterionHandler(duplicatedFilter * } } +func (qb *sceneFilterHandler) urlDuplicatedCriterionHandler(duplicatedFilter *models.URLDuplicationCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if duplicatedFilter != nil && duplicatedFilter.Duplicated != nil { + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + // Find URLs that appear on more than one scene + f.addInnerJoin("(SELECT scene_id FROM scene_urls INNER JOIN (SELECT url FROM scene_urls GROUP BY url HAVING COUNT(DISTINCT scene_id) "+v+" 1) dupes ON scene_urls.url = dupes.url)", "scurl", "scenes.id = scurl.scene_id") + } + } +} + func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if codec != nil { diff --git a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx index 7905dc4ae..3e67bdae6 100644 --- a/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx @@ -6,6 +6,7 @@ import { Option, SelectedList } from "./SidebarListFilter"; import { DuplicatedCriterionOption } from "src/models/list-filter/criteria/phash"; import { DuplicatedStashIDCriterionOption } from "src/models/list-filter/criteria/stash-ids"; import { DuplicatedTitleCriterionOption } from "src/models/list-filter/criteria/title"; +import { DuplicatedURLCriterionOption } from "src/models/list-filter/criteria/url"; import { SidebarSection } from "src/components/Shared/Sidebar"; import { Icon } from "src/components/Shared/Icon"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; @@ -16,6 +17,7 @@ const DUPLICATE_TYPES = { phash: DuplicatedCriterionOption, stash_id: DuplicatedStashIDCriterionOption, title: DuplicatedTitleCriterionOption, + url: DuplicatedURLCriterionOption, } as const; type DuplicateTypeId = keyof typeof DUPLICATE_TYPES; @@ -32,6 +34,7 @@ const DUPLICATE_TYPE_MESSAGE_IDS: Record = { phash: "media_info.phash", stash_id: "stash_id", title: "title", + url: "url", }; export const SidebarDuplicateFilter: React.FC = ({ diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 04464584b..6a7f4a3c9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1063,6 +1063,7 @@ "duplicated_phash": "Duplicated (pHash)", "duplicated_stash_id": "Duplicated (Stash ID)", "duplicated_title": "Duplicated (Title)", + "duplicated_url": "Duplicated (URL)", "duration": "Duration", "effect_filters": { "aspect": "Aspect", diff --git a/ui/v2.5/src/models/list-filter/criteria/url.ts b/ui/v2.5/src/models/list-filter/criteria/url.ts new file mode 100644 index 000000000..b5877e255 --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/url.ts @@ -0,0 +1,20 @@ +import { UrlDuplicationCriterionInput } from "src/core/generated-graphql"; +import { BooleanCriterionOption, StringCriterion } from "./criterion"; + +export const DuplicatedURLCriterionOption = new BooleanCriterionOption( + "duplicated_url", + "duplicated_url", + () => new DuplicatedURLCriterion() +); + +export class DuplicatedURLCriterion extends StringCriterion { + constructor() { + super(DuplicatedURLCriterionOption); + } + + public toCriterionInput(): UrlDuplicationCriterionInput { + return { + duplicated: this.value === "true", + }; + } +} diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 0bdaf238d..a405ad536 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -36,6 +36,7 @@ import { StashIDCriterionOption, } from "./criteria/stash-ids"; import { DuplicatedTitleCriterionOption } from "./criteria/title"; +import { DuplicatedURLCriterionOption } from "./criteria/url"; import { RatingCriterionOption } from "./criteria/rating"; import { PathCriterionOption } from "./criteria/path"; import { OrientationCriterionOption } from "./criteria/orientation"; @@ -106,6 +107,7 @@ const criterionOptions = [ DuplicatedCriterionOption, DuplicatedStashIDCriterionOption, DuplicatedTitleCriterionOption, + DuplicatedURLCriterionOption, OrganizedCriterionOption, RatingCriterionOption, createMandatoryNumberCriterionOption("o_counter", "o_count", { diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 9e0101468..db58f65fc 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -199,6 +199,7 @@ export type CriterionType = | "duplicated" | "duplicated_stash_id" | "duplicated_title" + | "duplicated_url" | "ignore_auto_tag" | "file_count" | "stash_id_endpoint"