diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 9e124e49e..75d1e6f2d 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -165,7 +165,9 @@ input SceneFilterType { """Filter by file checksum""" checksum: StringCriterionInput """Filter by file phash""" - phash: StringCriterionInput + phash: StringCriterionInput @deprecated(reason: "Use phash_distance instead") + """Filter by file phash distance""" + phash_distance: PhashDistanceCriterionInput """Filter by path""" path: StringCriterionInput """Filter by file count""" @@ -527,6 +529,12 @@ input TimestampCriterionInput { modifier: CriterionModifier! } +input PhashDistanceCriterionInput { + value: String! + modifier: CriterionModifier! + distance: Int +} + enum FilterMode { SCENES, PERFORMERS, diff --git a/pkg/models/filter.go b/pkg/models/filter.go index d614f262e..47e93f237 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -136,3 +136,9 @@ type TimestampCriterionInput struct { Value2 *string `json:"value2"` Modifier CriterionModifier `json:"modifier"` } + +type PhashDistanceCriterionInput struct { + Value string `json:"value"` + Modifier CriterionModifier `json:"modifier"` + Distance *int `json:"distance"` +} diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 55a27606a..cc503fa92 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -27,6 +27,8 @@ type SceneFilterType struct { Checksum *StringCriterionInput `json:"checksum"` // Filter by file phash Phash *StringCriterionInput `json:"phash"` + // Filter by phash distance + PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"` // Filter by path Path *StringCriterionInput `json:"path"` // Filter by file count diff --git a/pkg/sqlite/driver.go b/pkg/sqlite/driver.go index c67379d1b..d70676813 100644 --- a/pkg/sqlite/driver.go +++ b/pkg/sqlite/driver.go @@ -29,6 +29,7 @@ func (d *CustomSQLiteDriver) Open(dsn string) (driver.Conn, error) { "regexp": regexFn, "durationToTinyInt": durationToTinyIntFn, "basename": basenameFn, + "phash_distance": phashDistanceFn, } for name, fn := range funcs { diff --git a/pkg/sqlite/phash.go b/pkg/sqlite/phash.go new file mode 100644 index 000000000..ceda69bd4 --- /dev/null +++ b/pkg/sqlite/phash.go @@ -0,0 +1,10 @@ +package sqlite + +import "github.com/corona10/goimagehash" + +func phashDistanceFn(phash1 int64, phash2 int64) (int64, error) { + hash1 := goimagehash.NewImageHash(uint64(phash1), goimagehash.PHash) + hash2 := goimagehash.NewImageHash(uint64(phash2), goimagehash.PHash) + distance, _ := hash1.Distance(hash2) + return int64(distance), nil +} diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index ee58cb0e2..e478e4477 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -882,17 +882,16 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Phash != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") - - value, _ := utils.StringToPhash(sceneFilter.Phash.Value) - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), + // backwards compatibility + scenePhashDistanceCriterionHandler(qb, &models.PhashDistanceCriterionInput{ + Value: sceneFilter.Phash.Value, Modifier: sceneFilter.Phash.Modifier, - }, "fingerprints_phash.fingerprint", nil)(ctx, f) + })(ctx, f) } })) + query.handleCriterion(ctx, scenePhashDistanceCriterionHandler(qb, sceneFilter.PhashDistance)) + query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil)) // legacy rating handler query.handleCriterion(ctx, rating5CriterionHandler(sceneFilter.Rating, "scenes.rating", nil)) @@ -1382,6 +1381,45 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id } } +func scenePhashDistanceCriterionHandler(qb *SceneStore, phashDistance *models.PhashDistanceCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if phashDistance != nil { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + + value, _ := utils.StringToPhash(phashDistance.Value) + distance := 0 + if phashDistance.Distance != nil { + distance = *phashDistance.Distance + } + + if distance == 0 { + // use the default handler + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } + + switch { + case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) + case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) + default: + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } + } + } +} + func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindFilterType) { if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { return diff --git a/ui/v2.5/src/components/List/CriterionEditor.tsx b/ui/v2.5/src/components/List/CriterionEditor.tsx index 7e7e5c636..aac0c00f8 100644 --- a/ui/v2.5/src/components/List/CriterionEditor.tsx +++ b/ui/v2.5/src/components/List/CriterionEditor.tsx @@ -38,6 +38,8 @@ import { RatingFilter } from "./Filters/RatingFilter"; import { BooleanFilter } from "./Filters/BooleanFilter"; import { OptionsListFilter } from "./Filters/OptionsListFilter"; import { PathFilter } from "./Filters/PathFilter"; +import { PhashCriterion } from "src/models/list-filter/criteria/phash"; +import { PhashFilter } from "./Filters/PhashFilter"; interface IGenericCriterionEditor { criterion: Criterion; @@ -172,6 +174,11 @@ const GenericCriterionEditor: React.FC = ({ ); } + if (criterion instanceof PhashCriterion) { + return ( + + ); + } if ( criterion instanceof CountryCriterion && (criterion.modifier === CriterionModifier.Equals || diff --git a/ui/v2.5/src/components/List/Filters/PhashFilter.tsx b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx new file mode 100644 index 000000000..988f813b9 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/PhashFilter.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { IPhashDistanceValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; +import { CriterionModifier } from "src/core/generated-graphql"; + +interface IPhashFilterProps { + criterion: Criterion; + onValueChanged: (value: IPhashDistanceValue) => void; +} + +export const PhashFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + const { value } = criterion; + + function valueChanged(event: React.ChangeEvent) { + onValueChanged({ + value: event.target.value, + distance: criterion.value.distance, + }); + } + + function distanceChanged(event: React.ChangeEvent) { + let distance = parseInt(event.target.value); + if (distance < 0 || isNaN(distance)) { + distance = 0; + } + + onValueChanged({ + distance, + value: criterion.value.value, + }); + } + + return ( +
+ + + + {criterion.modifier !== CriterionModifier.IsNull && + criterion.modifier !== CriterionModifier.NotNull && ( + + + + )} +
+ ); +}; diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index a4b53dec7..7dc299a77 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -22,6 +22,7 @@ import { IStashIDValue, IDateValue, ITimestampValue, + IPhashDistanceValue, } from "../types"; export type Option = string | number | IOptionType; @@ -32,7 +33,8 @@ export type CriterionValue = | INumberValue | IStashIDValue | IDateValue - | ITimestampValue; + | ITimestampValue + | IPhashDistanceValue; const modifierMessageIDs = { [CriterionModifier.Equals]: "criterion_modifier.equals", diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index 28bec371b..f6c96cab8 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -48,7 +48,7 @@ import { MoviesCriterionOption } from "./movies"; import { GalleriesCriterion } from "./galleries"; import { CriterionType } from "../types"; import { InteractiveCriterion } from "./interactive"; -import { DuplicatedCriterion, PhashCriterionOption } from "./phash"; +import { DuplicatedCriterion, PhashCriterion } from "./phash"; import { CaptionCriterion } from "./captions"; import { RatingCriterion } from "./rating"; import { CountryCriterion } from "./country"; @@ -167,7 +167,7 @@ export function makeCriteria( new StringCriterionOption("media_info.checksum", type, "checksum") ); case "phash": - return new StringCriterion(PhashCriterionOption); + return new PhashCriterion(); case "duplicated": return new DuplicatedCriterion(); case "country": diff --git a/ui/v2.5/src/models/list-filter/criteria/phash.ts b/ui/v2.5/src/models/list-filter/criteria/phash.ts index 25bc8f6e7..b8dc48f21 100644 --- a/ui/v2.5/src/models/list-filter/criteria/phash.ts +++ b/ui/v2.5/src/models/list-filter/criteria/phash.ts @@ -1,15 +1,19 @@ -import { CriterionModifier } from "src/core/generated-graphql"; +import { + CriterionModifier, + PhashDistanceCriterionInput, +} from "src/core/generated-graphql"; +import { IPhashDistanceValue } from "../types"; import { BooleanCriterionOption, + Criterion, CriterionOption, PhashDuplicateCriterion, - StringCriterion, } from "./criterion"; export const PhashCriterionOption = new CriterionOption({ messageID: "media_info.phash", type: "phash", - parameterName: "phash", + parameterName: "phash_distance", inputType: "text", modifierOptions: [ CriterionModifier.Equals, @@ -19,9 +23,30 @@ export const PhashCriterionOption = new CriterionOption({ ], }); -export class PhashCriterion extends StringCriterion { +export class PhashCriterion extends Criterion { constructor() { - super(PhashCriterionOption); + super(PhashCriterionOption, { value: "", distance: 0 }); + } + + public getLabelValue() { + const { value, distance } = this.value; + if ( + (this.modifier === CriterionModifier.Equals || + this.modifier === CriterionModifier.NotEquals) && + distance + ) { + return `${value} (${distance})`; + } else { + return `${value}`; + } + } + + protected toCriterionInput(): PhashDistanceCriterionInput { + return { + value: this.value.value, + modifier: this.modifier, + distance: this.value.distance, + }; } } diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 3dd9e589c..e105e8ab8 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -48,6 +48,11 @@ export interface ITimestampValue { value2: string | undefined; } +export interface IPhashDistanceValue { + value: string; + distance?: number; +} + export function criterionIsHierarchicalLabelValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index e9a0bb324..d33a00a74 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -289,7 +289,7 @@ const makeScenesPHashMatchUrl = (phash: GQL.Maybe | undefined) => { if (!phash) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new PhashCriterion(); - criterion.value = phash; + criterion.value = { value: phash }; filter.criteria.push(criterion); return `/scenes?${filter.makeQueryParameters()}`; };