mirror of
https://github.com/stashapp/stash.git
synced 2026-02-25 16:54:44 +01:00
Feature: Scene Duplicate Filter (#6344)
This commit is contained in:
parent
26db935fad
commit
d1479ca4e5
14 changed files with 510 additions and 58 deletions
|
|
@ -75,10 +75,26 @@ input OrientationCriterionInput {
|
|||
value: [OrientationEnum!]!
|
||||
}
|
||||
|
||||
input PHashDuplicationCriterionInput {
|
||||
duplicated: Boolean
|
||||
"Currently unimplemented"
|
||||
input DuplicationCriterionInput {
|
||||
duplicated: Boolean @deprecated(reason: "Use phash field instead")
|
||||
"Currently unimplemented. Intended for phash distance matching."
|
||||
distance: Int
|
||||
"Filter by phash duplication"
|
||||
phash: Boolean
|
||||
"Filter by URL duplication"
|
||||
url: Boolean
|
||||
"Filter by Stash ID duplication"
|
||||
stash_id: Boolean
|
||||
"Filter by title duplication"
|
||||
title: Boolean
|
||||
}
|
||||
|
||||
input FileDuplicationCriterionInput {
|
||||
duplicated: Boolean @deprecated(reason: "Use phash field instead")
|
||||
"Currently unimplemented. Intended for phash distance matching."
|
||||
distance: Int
|
||||
"Filter by phash duplication"
|
||||
phash: Boolean
|
||||
}
|
||||
|
||||
input StashIDCriterionInput {
|
||||
|
|
@ -261,8 +277,8 @@ input SceneFilterType {
|
|||
organized: Boolean
|
||||
"Filter by o-counter"
|
||||
o_counter: IntCriterionInput
|
||||
"Filter Scenes that have an exact phash match available"
|
||||
duplicated: PHashDuplicationCriterionInput
|
||||
"Filter Scenes by duplication criteria"
|
||||
duplicated: DuplicationCriterionInput
|
||||
"Filter by resolution"
|
||||
resolution: ResolutionCriterionInput
|
||||
"Filter by orientation"
|
||||
|
|
@ -744,8 +760,8 @@ input FileFilterType {
|
|||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
|
||||
"Filter files that have an exact match available"
|
||||
duplicated: PHashDuplicationCriterionInput
|
||||
"Filter files by duplication criteria (only phash applies to files)"
|
||||
duplicated: FileDuplicationCriterionInput
|
||||
|
||||
"find files based on hash"
|
||||
hashes: [FingerprintFilterInput!]
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ type FileFilterType struct {
|
|||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file"`
|
||||
ModTime *TimestampCriterionInput `json:"mod_time"`
|
||||
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
|
||||
Duplicated *FileDuplicationCriterionInput `json:"duplicated"`
|
||||
Hashes []*FingerprintFilterInput `json:"hashes"`
|
||||
VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"`
|
||||
ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"`
|
||||
|
|
|
|||
|
|
@ -2,10 +2,28 @@ package models
|
|||
|
||||
import "context"
|
||||
|
||||
type PHashDuplicationCriterionInput struct {
|
||||
type DuplicationCriterionInput struct {
|
||||
// Deprecated: Use Phash field instead. Kept for backwards compatibility.
|
||||
Duplicated *bool `json:"duplicated"`
|
||||
// Currently unimplemented
|
||||
// Currently unimplemented. Intended for phash distance matching.
|
||||
Distance *int `json:"distance"`
|
||||
// Filter by phash duplication
|
||||
Phash *bool `json:"phash"`
|
||||
// Filter by URL duplication
|
||||
URL *bool `json:"url"`
|
||||
// Filter by Stash ID duplication
|
||||
StashID *bool `json:"stash_id"`
|
||||
// Filter by title duplication
|
||||
Title *bool `json:"title"`
|
||||
}
|
||||
|
||||
type FileDuplicationCriterionInput struct {
|
||||
// Deprecated: Use Phash field instead. Kept for backwards compatibility.
|
||||
Duplicated *bool `json:"duplicated"`
|
||||
// Currently unimplemented. Intended for phash distance matching.
|
||||
Distance *int `json:"distance"`
|
||||
// Filter by phash duplication
|
||||
Phash *bool `json:"phash"`
|
||||
}
|
||||
|
||||
type SceneFilterType struct {
|
||||
|
|
@ -33,8 +51,8 @@ type SceneFilterType struct {
|
|||
Organized *bool `json:"organized"`
|
||||
// Filter by o-counter
|
||||
OCounter *IntCriterionInput `json:"o_counter"`
|
||||
// Filter Scenes that have an exact phash match available
|
||||
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"`
|
||||
// Filter Scenes by duplication criteria
|
||||
Duplicated *DuplicationCriterionInput `json:"duplicated"`
|
||||
// Filter by resolution
|
||||
Resolution *ResolutionCriterionInput `json:"resolution"`
|
||||
// Filter by orientation
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler {
|
|||
|
||||
qb.hashesCriterionHandler(fileFilter.Hashes),
|
||||
|
||||
qb.phashDuplicatedCriterionHandler(fileFilter.Duplicated),
|
||||
qb.duplicatedCriterionHandler(fileFilter.Duplicated),
|
||||
×tampCriterionHandler{fileFilter.CreatedAt, "files.created_at", nil},
|
||||
×tampCriterionHandler{fileFilter.UpdatedAt, "files.updated_at", nil},
|
||||
|
||||
|
|
@ -205,17 +205,27 @@ func (qb *fileFilterHandler) galleryCountCriterionHandler(c *models.IntCriterion
|
|||
return h.handler(c)
|
||||
}
|
||||
|
||||
func (qb *fileFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc {
|
||||
func (qb *fileFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.FileDuplicationCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
// TODO: Wishlist item: Implement Distance matching
|
||||
if duplicatedFilter != nil {
|
||||
var v string
|
||||
if *duplicatedFilter.Duplicated {
|
||||
v = ">"
|
||||
} else {
|
||||
v = "="
|
||||
}
|
||||
// For files, only phash duplication applies
|
||||
if duplicatedFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var phashValue *bool
|
||||
|
||||
// Handle legacy 'duplicated' field for backwards compatibility
|
||||
//nolint:staticcheck
|
||||
if duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil {
|
||||
//nolint:staticcheck
|
||||
phashValue = duplicatedFilter.Duplicated
|
||||
} else if duplicatedFilter.Phash != nil {
|
||||
phashValue = duplicatedFilter.Phash
|
||||
}
|
||||
|
||||
if phashValue != nil {
|
||||
v := getCountOperator(*phashValue)
|
||||
f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "files.id = scph.file_id")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
|
|||
qb.performerTagsCriterionHandler(sceneFilter.PerformerTags),
|
||||
qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite),
|
||||
qb.performerAgeCriterionHandler(sceneFilter.PerformerAge),
|
||||
qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable),
|
||||
qb.duplicatedCriterionHandler(sceneFilter.Duplicated),
|
||||
&dateCriterionHandler{sceneFilter.Date, "scenes.date", nil},
|
||||
×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil},
|
||||
×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil},
|
||||
|
|
@ -296,26 +296,71 @@ func (qb *sceneFilterHandler) fileCountCriterionHandler(fileCount *models.IntCri
|
|||
return h.handler(fileCount)
|
||||
}
|
||||
|
||||
func (qb *sceneFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
func (qb *sceneFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.DuplicationCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
// TODO: Wishlist item: Implement Distance matching
|
||||
if duplicatedFilter != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
if duplicatedFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var v string
|
||||
if *duplicatedFilter.Duplicated {
|
||||
v = ">"
|
||||
} else {
|
||||
v = "="
|
||||
}
|
||||
// Handle legacy 'duplicated' field - treat as phash if phash not explicitly set
|
||||
//nolint:staticcheck
|
||||
if duplicatedFilter.Duplicated != nil && duplicatedFilter.Phash == nil {
|
||||
//nolint:staticcheck
|
||||
duplicatedFilter.Phash = duplicatedFilter.Duplicated
|
||||
}
|
||||
|
||||
f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id")
|
||||
// Handle explicit fields
|
||||
if duplicatedFilter.Phash != nil {
|
||||
qb.addSceneFilesTable(f)
|
||||
qb.applyPhashDuplication(f, *duplicatedFilter.Phash)
|
||||
}
|
||||
|
||||
if duplicatedFilter.StashID != nil {
|
||||
qb.applyStashIDDuplication(f, *duplicatedFilter.StashID)
|
||||
}
|
||||
|
||||
if duplicatedFilter.Title != nil {
|
||||
qb.applyTitleDuplication(f, *duplicatedFilter.Title)
|
||||
}
|
||||
|
||||
if duplicatedFilter.URL != nil {
|
||||
qb.applyURLDuplication(f, *duplicatedFilter.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getCountOperator returns ">" for duplicated items (count > 1) or "=" for unique items (count = 1)
|
||||
func getCountOperator(duplicated bool) string {
|
||||
if duplicated {
|
||||
return ">"
|
||||
}
|
||||
return "="
|
||||
}
|
||||
|
||||
func (qb *sceneFilterHandler) applyPhashDuplication(f *filterBuilder, duplicated bool) {
|
||||
// TODO: Wishlist item: Implement Distance matching
|
||||
v := getCountOperator(duplicated)
|
||||
f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id")
|
||||
}
|
||||
|
||||
func (qb *sceneFilterHandler) applyStashIDDuplication(f *filterBuilder, duplicated bool) {
|
||||
v := getCountOperator(duplicated)
|
||||
// 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) applyTitleDuplication(f *filterBuilder, duplicated bool) {
|
||||
v := getCountOperator(duplicated)
|
||||
// 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) applyURLDuplication(f *filterBuilder, duplicated bool) {
|
||||
v := getCountOperator(duplicated)
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -4121,7 +4121,7 @@ func TestSceneQueryPhashDuplicated(t *testing.T) {
|
|||
withTxn(func(ctx context.Context) error {
|
||||
sqb := db.Scene
|
||||
duplicated := true
|
||||
phashCriterion := models.PHashDuplicationCriterionInput{
|
||||
phashCriterion := models.DuplicationCriterionInput{
|
||||
Duplicated: &duplicated,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,8 +42,12 @@ import { StudiosCriterion } from "src/models/list-filter/criteria/studios";
|
|||
import StudiosFilter from "./Filters/StudiosFilter";
|
||||
import { TagsCriterion } from "src/models/list-filter/criteria/tags";
|
||||
import TagsFilter from "./Filters/TagsFilter";
|
||||
import { PhashCriterion } from "src/models/list-filter/criteria/phash";
|
||||
import {
|
||||
PhashCriterion,
|
||||
DuplicatedCriterion,
|
||||
} from "src/models/list-filter/criteria/phash";
|
||||
import { PhashFilter } from "./Filters/PhashFilter";
|
||||
import { DuplicatedFilter } from "./Filters/DuplicateFilter";
|
||||
import { PathCriterion } from "src/models/list-filter/criteria/path";
|
||||
import { ModifierSelectorButtons } from "./ModifierSelect";
|
||||
import { CustomFieldsCriterion } from "src/models/list-filter/criteria/custom-fields";
|
||||
|
|
@ -273,6 +277,12 @@ export const CriterionEditor: React.FC<ICriterionEditor> = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (criterion instanceof DuplicatedCriterion) {
|
||||
return (
|
||||
<DuplicatedFilter criterion={criterion} setCriterion={setCriterion} />
|
||||
);
|
||||
}
|
||||
|
||||
if (criterion instanceof CustomFieldsCriterion) {
|
||||
return (
|
||||
<CustomFieldsFilter criterion={criterion} setCriterion={setCriterion} />
|
||||
|
|
|
|||
227
ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx
Normal file
227
ui/v2.5/src/components/List/Filters/DuplicateFilter.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { Option, SelectedList } from "./SidebarListFilter";
|
||||
import {
|
||||
DuplicatedCriterion,
|
||||
DuplicatedCriterionOption,
|
||||
DuplicationFieldId,
|
||||
DUPLICATION_FIELD_IDS,
|
||||
DUPLICATION_FIELD_MESSAGE_IDS,
|
||||
} from "src/models/list-filter/criteria/phash";
|
||||
import { IndeterminateCheckbox } from "src/components/Shared/IndeterminateCheckbox";
|
||||
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";
|
||||
|
||||
interface IDuplicatedFilter {
|
||||
criterion: DuplicatedCriterion;
|
||||
setCriterion: (c: DuplicatedCriterion) => void;
|
||||
}
|
||||
|
||||
export const DuplicatedFilter: React.FC<IDuplicatedFilter> = ({
|
||||
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 (
|
||||
<div className="duplicated-filter">
|
||||
{DUPLICATION_FIELD_IDS.map((fieldId) => (
|
||||
<IndeterminateCheckbox
|
||||
key={fieldId}
|
||||
id={`duplicated-${fieldId}`}
|
||||
label={intl.formatMessage({
|
||||
id: DUPLICATION_FIELD_MESSAGE_IDS[fieldId],
|
||||
})}
|
||||
checked={criterion.value[fieldId]}
|
||||
setChecked={(v) => onFieldChange(fieldId, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISidebarDuplicateFilterProps {
|
||||
title?: React.ReactNode;
|
||||
filter: ListFilterModel;
|
||||
setFilter: (f: ListFilterModel) => void;
|
||||
sectionID?: string;
|
||||
}
|
||||
|
||||
export const SidebarDuplicateFilter: React.FC<ISidebarDuplicateFilterProps> = ({
|
||||
title,
|
||||
filter,
|
||||
setFilter,
|
||||
sectionID,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [expandedType, setExpandedType] = useState<string | null>(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 (
|
||||
<SidebarSection
|
||||
className="sidebar-list-filter"
|
||||
text={title}
|
||||
sectionID={sectionID}
|
||||
outsideCollapse={
|
||||
<SelectedList items={selected} onUnselect={(i) => onUnselect(i)} />
|
||||
}
|
||||
>
|
||||
<div className="queryable-candidate-list">
|
||||
<ul>
|
||||
{options.map((opt) => (
|
||||
<React.Fragment key={opt.id}>
|
||||
<li className="unselected-object">
|
||||
<a
|
||||
onClick={() => onToggleExpand(opt.id)}
|
||||
onKeyDown={keyboardClickHandler(() => onToggleExpand(opt.id))}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="label-group">
|
||||
<Icon
|
||||
className="fa-fw include-button single-value"
|
||||
icon={faPlus}
|
||||
/>
|
||||
<span className="unselected-object-label">{opt.label}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{expandedType === opt.id && (
|
||||
<div className="duplicate-sub-options">
|
||||
<div
|
||||
className="duplicate-sub-option"
|
||||
onClick={() => onSelectValue(opt.id, true)}
|
||||
>
|
||||
{trueLabel}
|
||||
</div>
|
||||
<div
|
||||
className="duplicate-sub-option"
|
||||
onClick={() => onSelectValue(opt.id, false)}
|
||||
>
|
||||
{falseLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import { OrganizedCriterionOption } from "src/models/list-filter/criteria/organi
|
|||
import { HasMarkersCriterionOption } from "src/models/list-filter/criteria/has-markers";
|
||||
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
|
||||
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 {
|
||||
|
|
@ -320,6 +321,12 @@ const SidebarContent: React.FC<{
|
|||
setFilter={setFilter}
|
||||
sectionID="organized"
|
||||
/>
|
||||
<SidebarDuplicateFilter
|
||||
title={<FormattedMessage id="duplicated" />}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
sectionID="duplicated"
|
||||
/>
|
||||
<SidebarAgeFilter
|
||||
title={<FormattedMessage id="performer_age" />}
|
||||
option={PerformerAgeCriterionOption}
|
||||
|
|
|
|||
|
|
@ -1092,7 +1092,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",
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
modifier: CriterionModifier;
|
||||
|
|
|
|||
|
|
@ -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<IPhashDistanceValue> {
|
|||
}
|
||||
}
|
||||
|
||||
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<IDuplicationValue> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,9 +47,15 @@ export interface IRangeValue<V> {
|
|||
export type INumberValue = IRangeValue<number>;
|
||||
export type IDateValue = IRangeValue<string>;
|
||||
export type ITimestampValue = IRangeValue<string>;
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue