This commit is contained in:
Gykes 2025-12-04 03:04:27 +02:00 committed by GitHub
commit 2c9c20c1ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 484 additions and 15 deletions

View file

@ -81,6 +81,18 @@ input PHashDuplicationCriterionInput {
distance: Int distance: Int
} }
input StashIDDuplicationCriterionInput {
duplicated: Boolean
}
input TitleDuplicationCriterionInput {
duplicated: Boolean
}
input URLDuplicationCriterionInput {
duplicated: Boolean
}
input StashIDCriterionInput { input StashIDCriterionInput {
""" """
If present, this value is treated as a predicate. If present, this value is treated as a predicate.
@ -250,6 +262,12 @@ input SceneFilterType {
o_counter: IntCriterionInput o_counter: IntCriterionInput
"Filter Scenes that have an exact phash match available" "Filter Scenes that have an exact phash match available"
duplicated: PHashDuplicationCriterionInput 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 Scenes that have the same URL"
duplicated_url: URLDuplicationCriterionInput
"Filter by resolution" "Filter by resolution"
resolution: ResolutionCriterionInput resolution: ResolutionCriterionInput
"Filter by orientation" "Filter by orientation"

View file

@ -8,6 +8,18 @@ type PHashDuplicationCriterionInput struct {
Distance *int `json:"distance"` Distance *int `json:"distance"`
} }
type StashIDDuplicationCriterionInput struct {
Duplicated *bool `json:"duplicated"`
}
type TitleDuplicationCriterionInput struct {
Duplicated *bool `json:"duplicated"`
}
type URLDuplicationCriterionInput struct {
Duplicated *bool `json:"duplicated"`
}
type SceneFilterType struct { type SceneFilterType struct {
OperatorFilter[SceneFilterType] OperatorFilter[SceneFilterType]
ID *IntCriterionInput `json:"id"` ID *IntCriterionInput `json:"id"`
@ -35,6 +47,12 @@ type SceneFilterType struct {
OCounter *IntCriterionInput `json:"o_counter"` OCounter *IntCriterionInput `json:"o_counter"`
// Filter Scenes that have an exact phash match available // Filter Scenes that have an exact phash match available
Duplicated *PHashDuplicationCriterionInput `json:"duplicated"` 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 Scenes that have the same URL
DuplicatedURL *URLDuplicationCriterionInput `json:"duplicated_url"`
// Filter by resolution // Filter by resolution
Resolution *ResolutionCriterionInput `json:"resolution"` Resolution *ResolutionCriterionInput `json:"resolution"`
// Filter by orientation // Filter by orientation

View file

@ -12,6 +12,7 @@ import (
"github.com/doug-martin/goqu/v9/exp" "github.com/doug-martin/goqu/v9/exp"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
"gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4"
"gopkg.in/guregu/null.v4/zero" "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() { if newObject.URLs.Loaded() {
urls := newObject.URLs.List()
utils.SortURLs(urls)
const startPos = 0 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 return err
} }
} }
@ -286,7 +289,9 @@ func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Galler
} }
if updatedObject.URLs.Loaded() { 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 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 { if err := galleriesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {
return nil, err 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 partial.PerformerIDs != nil {
if err := galleriesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { if err := galleriesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil {

View file

@ -14,6 +14,7 @@ import (
"gopkg.in/guregu/null.v4/zero" "gopkg.in/guregu/null.v4/zero"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
) )
const ( const (
@ -172,8 +173,10 @@ func (qb *GroupStore) Create(ctx context.Context, newObject *models.Group) error
} }
if newObject.URLs.Loaded() { if newObject.URLs.Loaded() {
urls := newObject.URLs.List()
utils.SortURLs(urls)
const startPos = 0 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 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 { if err := groupsURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {
return nil, err 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 { 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 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 return err
} }
} }

View file

@ -11,6 +11,7 @@ import (
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils"
"gopkg.in/guregu/null.v4" "gopkg.in/guregu/null.v4"
"gopkg.in/guregu/null.v4/zero" "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() { if newObject.URLs.Loaded() {
urls := newObject.URLs.List()
utils.SortURLs(urls)
const startPos = 0 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 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 { if err := imagesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {
return nil, err 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 partial.PerformerIDs != nil {
if err := imagesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != 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 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 return err
} }
} }

View file

@ -269,8 +269,10 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.CreatePe
} }
if newObject.URLs.Loaded() { if newObject.URLs.Loaded() {
urls := newObject.URLs.List()
utils.SortURLs(urls)
const startPos = 0 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 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 { if err := performersURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {
return nil, err 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 { if partial.TagIDs != nil {
@ -362,7 +373,9 @@ func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Upda
} }
if updatedObject.URLs.Loaded() { 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 return err
} }
} }

View file

@ -445,7 +445,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) {
url = "url" url = "url"
twitter = "twitter" twitter = "twitter"
instagram = "instagram" instagram = "instagram"
urls = []string{url, twitter, instagram} urls = []string{instagram, twitter, url} // sorted alphabetically
rating = 3 rating = 3
ethnicity = "ethnicity" ethnicity = "ethnicity"
country = "country" country = "country"

View file

@ -315,8 +315,10 @@ func (qb *SceneStore) Create(ctx context.Context, newObject *models.Scene, fileI
} }
if newObject.URLs.Loaded() { if newObject.URLs.Loaded() {
urls := newObject.URLs.List()
utils.SortURLs(urls)
const startPos = 0 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 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 { if err := scenesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil {
return nil, err 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 partial.PerformerIDs != nil {
if err := scenesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != 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 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 return err
} }
} }

View file

@ -156,6 +156,9 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler {
qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite),
qb.performerAgeCriterionHandler(sceneFilter.PerformerAge), qb.performerAgeCriterionHandler(sceneFilter.PerformerAge),
qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable), qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable),
qb.stashIDDuplicatedCriterionHandler(sceneFilter.DuplicatedStashID),
qb.titleDuplicatedCriterionHandler(sceneFilter.DuplicatedTitle),
qb.urlDuplicatedCriterionHandler(sceneFilter.DuplicatedURL),
&dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil},
&timestampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, &timestampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil},
&timestampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, &timestampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil},
@ -297,6 +300,54 @@ 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) 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 { func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if codec != nil { if codec != nil {

View file

@ -15,6 +15,7 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/utils"
) )
const ( const (
@ -191,8 +192,10 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err
} }
if newObject.URLs.Loaded() { if newObject.URLs.Loaded() {
urls := newObject.URLs.List()
utils.SortURLs(urls)
const startPos = 0 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 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 { if err := studiosURLsTableMgr.modifyJoins(ctx, input.ID, input.URLs.Values, input.URLs.Mode); err != nil {
return nil, err 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 { 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 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 return err
} }
} }

View file

@ -1,6 +1,10 @@
package utils package utils
import "regexp" import (
"regexp"
"sort"
"strings"
)
// URLFromHandle adds the site URL to the input if the input is not already a URL // URLFromHandle adds the site URL to the input if the input is not already a URL
// siteURL must not end with a slash // siteURL must not end with a slash
@ -13,3 +17,21 @@ func URLFromHandle(input string, siteURL string) string {
return siteURL + "/" + input 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])
})
}

View file

@ -0,0 +1,177 @@
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";
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";
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,
url: DuplicatedURLCriterionOption,
} as const;
type DuplicateTypeId = keyof typeof DUPLICATE_TYPES;
interface ISidebarDuplicateFilterProps {
title?: React.ReactNode;
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
sectionID?: string;
}
// i18n message IDs for each duplicate type
const DUPLICATE_TYPE_MESSAGE_IDS: Record<DuplicateTypeId, string> = {
phash: "media_info.phash",
stash_id: "stash_id",
title: "title",
url: "url",
};
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: DuplicateTypeId) =>
intl.formatMessage({ id: DUPLICATE_TYPE_MESSAGE_IDS[typeId] }),
[intl]
);
// Get criterion for a given type
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(() => {
const result: Option[] = [];
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: `${getLabel(typeId)}: ${valueLabel}`,
});
}
}
return result;
}, [getCriterion, trueLabel, falseLabel, getLabel]);
// 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: getLabel(typeId) });
}
}
return result;
}, [getCriterion, getLabel]);
function onToggleExpand(id: string) {
setExpandedType(expandedType === id ? null : id);
}
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(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(criterionOption.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>
);
};

View file

@ -726,6 +726,24 @@ input[type="range"].zoom-slider {
min-height: 2em; 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 { .tilted {
transform: rotate(45deg); transform: rotate(45deg);
} }

View file

@ -60,6 +60,7 @@ import {
DurationCriterionOption, DurationCriterionOption,
PerformerAgeCriterionOption, PerformerAgeCriterionOption,
} from "src/models/list-filter/scenes"; } from "src/models/list-filter/scenes";
import { SidebarDuplicateFilter } from "../List/Filters/DuplicateFilter";
import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter"; import { SidebarAgeFilter } from "../List/Filters/SidebarAgeFilter";
import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter"; import { SidebarDurationFilter } from "../List/Filters/SidebarDurationFilter";
import { import {
@ -347,6 +348,12 @@ const SidebarContent: React.FC<{
setFilter={setFilter} setFilter={setFilter}
sectionID="organized" sectionID="organized"
/> />
<SidebarDuplicateFilter
title={<FormattedMessage id="duplicated" />}
filter={filter}
setFilter={setFilter}
sectionID="duplicated"
/>
<SidebarAgeFilter <SidebarAgeFilter
title={<FormattedMessage id="performer_age" />} title={<FormattedMessage id="performer_age" />}
option={PerformerAgeCriterionOption} option={PerformerAgeCriterionOption}

View file

@ -1062,7 +1062,11 @@
"select_youngest": "Select the youngest file in the duplicate group", "select_youngest": "Select the youngest file in the duplicate group",
"title": "Duplicate Scenes" "title": "Duplicate Scenes"
}, },
"duplicated": "Duplicated",
"duplicated_phash": "Duplicated (pHash)", "duplicated_phash": "Duplicated (pHash)",
"duplicated_stash_id": "Duplicated (Stash ID)",
"duplicated_title": "Duplicated (Title)",
"duplicated_url": "Duplicated (URL)",
"duration": "Duration", "duration": "Duration",
"effect_filters": { "effect_filters": {
"aspect": "Aspect", "aspect": "Aspect",

View file

@ -3,12 +3,15 @@ import { IntlShape } from "react-intl";
import { import {
CriterionModifier, CriterionModifier,
StashIdCriterionInput, StashIdCriterionInput,
StashIdDuplicationCriterionInput,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { IStashIDValue } from "../types"; import { IStashIDValue } from "../types";
import { import {
BooleanCriterionOption,
ISavedCriterion, ISavedCriterion,
ModifierCriterion, ModifierCriterion,
ModifierCriterionOption, ModifierCriterionOption,
StringCriterion,
} from "./criterion"; } from "./criterion";
export const StashIDCriterionOption = new ModifierCriterionOption({ export const StashIDCriterionOption = new ModifierCriterionOption({
@ -145,3 +148,21 @@ export class StashIDCriterion extends ModifierCriterion<IStashIDValue> {
); );
} }
} }
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",
};
}
}

View file

@ -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",
};
}
}

View file

@ -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",
};
}
}

View file

@ -31,7 +31,12 @@ import {
} from "./criteria/phash"; } from "./criteria/phash";
import { PerformerFavoriteCriterionOption } from "./criteria/favorite"; import { PerformerFavoriteCriterionOption } from "./criteria/favorite";
import { CaptionsCriterionOption } from "./criteria/captions"; import { CaptionsCriterionOption } from "./criteria/captions";
import { StashIDCriterionOption } from "./criteria/stash-ids"; import {
DuplicatedStashIDCriterionOption,
StashIDCriterionOption,
} from "./criteria/stash-ids";
import { DuplicatedTitleCriterionOption } from "./criteria/title";
import { DuplicatedURLCriterionOption } from "./criteria/url";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
import { PathCriterionOption } from "./criteria/path"; import { PathCriterionOption } from "./criteria/path";
import { OrientationCriterionOption } from "./criteria/orientation"; import { OrientationCriterionOption } from "./criteria/orientation";
@ -100,6 +105,9 @@ const criterionOptions = [
createStringCriterionOption("checksum", "media_info.checksum"), createStringCriterionOption("checksum", "media_info.checksum"),
PhashCriterionOption, PhashCriterionOption,
DuplicatedCriterionOption, DuplicatedCriterionOption,
DuplicatedStashIDCriterionOption,
DuplicatedTitleCriterionOption,
DuplicatedURLCriterionOption,
OrganizedCriterionOption, OrganizedCriterionOption,
RatingCriterionOption, RatingCriterionOption,
createMandatoryNumberCriterionOption("o_counter", "o_count", { createMandatoryNumberCriterionOption("o_counter", "o_count", {

View file

@ -197,6 +197,9 @@ export type CriterionType =
| "favorite" | "favorite"
| "performer_age" | "performer_age"
| "duplicated" | "duplicated"
| "duplicated_stash_id"
| "duplicated_title"
| "duplicated_url"
| "ignore_auto_tag" | "ignore_auto_tag"
| "file_count" | "file_count"
| "stash_id_endpoint" | "stash_id_endpoint"