Support filtering by StashID endpoint (#3005)

* Add endpoint to stash_id filter in UI

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
stg-annon
2022-11-16 18:08:15 -05:00
committed by GitHub
parent ca9c8e0a34
commit 3660bf2d1a
22 changed files with 481 additions and 7 deletions

View File

@@ -39,6 +39,14 @@ input PHashDuplicationCriterionInput {
distance: Int
}
input StashIDCriterionInput {
"""If present, this value is treated as a predicate.
That is, it will filter based on stash_ids with the matching endpoint"""
endpoint: String
stash_id: String
modifier: CriterionModifier!
}
input PerformerFilterType {
AND: PerformerFilterType
OR: PerformerFilterType
@@ -90,7 +98,9 @@ input PerformerFilterType {
"""Filter by gallery count"""
gallery_count: IntCriterionInput
"""Filter by StashID"""
stash_id: StringCriterionInput
stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead")
"""Filter by StashID"""
stash_id_endpoint: StashIDCriterionInput
"""Filter by rating"""
rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
@@ -196,7 +206,9 @@ input SceneFilterType {
"""Filter by performer count"""
performer_count: IntCriterionInput
"""Filter by StashID"""
stash_id: StringCriterionInput
stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead")
"""Filter by StashID"""
stash_id_endpoint: StashIDCriterionInput
"""Filter by url"""
url: StringCriterionInput
"""Filter by interactive"""
@@ -251,7 +263,9 @@ input StudioFilterType {
"""Filter to only include studios with this parent studio"""
parents: MultiCriterionInput
"""Filter by StashID"""
stash_id: StringCriterionInput
stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead")
"""Filter by StashID"""
stash_id_endpoint: StashIDCriterionInput
"""Filter to only include studios missing this property"""
is_missing: String
"""Filter by rating"""

View File

@@ -111,6 +111,8 @@ type PerformerFilterType struct {
GalleryCount *IntCriterionInput `json:"gallery_count"`
// Filter by StashID
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by rating expressed as 1-5
Rating *IntCriterionInput `json:"rating"`
// Filter by rating expressed as 1-100

View File

@@ -69,6 +69,8 @@ type SceneFilterType struct {
PerformerCount *IntCriterionInput `json:"performer_count"`
// Filter by StashID
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by url
URL *StringCriterionInput `json:"url"`
// Filter by interactive

View File

@@ -20,3 +20,11 @@ func (u *UpdateStashIDs) AddUnique(v StashID) {
u.StashIDs = append(u.StashIDs, v)
}
type StashIDCriterionInput struct {
// If present, this value is treated as a predicate.
// That is, it will filter based on stash_ids with the matching endpoint
Endpoint *string `json:"endpoint"`
StashID *string `json:"stash_id"`
Modifier CriterionModifier `json:"modifier"`
}

View File

@@ -12,6 +12,8 @@ type StudioFilterType struct {
Parents *MultiCriterionInput `json:"parents"`
// Filter by StashID
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter to only include studios missing this property
IsMissing *string `json:"is_missing"`
// Filter by rating expressed as 1-5

View File

@@ -942,3 +942,39 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *mode
}
}
}
type stashIDCriterionHandler struct {
c *models.StashIDCriterionInput
stashIDRepository *stashIDRepository
stashIDTableAs string
parentIDCol string
}
func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) {
if h.c == nil {
return
}
stashIDRepo := h.stashIDRepository
t := stashIDRepo.tableName
if h.stashIDTableAs != "" {
t = h.stashIDTableAs
}
joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol)
if h.c.Endpoint != nil && *h.c.Endpoint != "" {
joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint)
}
f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause)
v := ""
if h.c.StashID != nil {
v = *h.c.StashID
}
stringCriterionHandler(&models.StringCriterionInput{
Value: v,
Modifier: h.c.Modifier,
}, t+".stash_id")(ctx, f)
}

View File

@@ -533,6 +533,12 @@ func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.Perform
stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f)
}
}))
query.handleCriterion(ctx, &stashIDCriterionHandler{
c: filter.StashIDEndpoint,
stashIDRepository: qb.stashIDRepository(),
stashIDTableAs: "performer_stash_ids",
parentIDCol: "performers.id",
})
// TODO - need better handling of aliases
query.handleCriterion(ctx, stringCriterionHandler(filter.Aliases, tableName+".aliases"))

View File

@@ -585,6 +585,100 @@ func TestPerformerQueryIgnoreAutoTag(t *testing.T) {
})
}
func TestPerformerQuery(t *testing.T) {
var (
endpoint = performerStashID(performerIdxWithGallery).Endpoint
stashID = performerStashID(performerIdxWithGallery).StashID
)
tests := []struct {
name string
findFilter *models.FindFilterType
filter *models.PerformerFilterType
includeIdxs []int
excludeIdxs []int
wantErr bool
}{
{
"stash id with endpoint",
nil,
&models.PerformerFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
StashID: &stashID,
Modifier: models.CriterionModifierEquals,
},
},
[]int{performerIdxWithGallery},
nil,
false,
},
{
"exclude stash id with endpoint",
nil,
&models.PerformerFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
StashID: &stashID,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{performerIdxWithGallery},
false,
},
{
"null stash id with endpoint",
nil,
&models.PerformerFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{performerIdxWithGallery},
false,
},
{
"not null stash id with endpoint",
nil,
&models.PerformerFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{performerIdxWithGallery},
nil,
false,
},
}
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
performers, _, err := db.Performer.Query(ctx, tt.filter, tt.findFilter)
if (err != nil) != tt.wantErr {
t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr)
return
}
ids := performersToIDs(performers)
include := indexesToIDs(performerIDs, tt.includeIdxs)
exclude := indexesToIDs(performerIDs, tt.excludeIdxs)
for _, i := range include {
assert.Contains(ids, i)
}
for _, e := range exclude {
assert.NotContains(ids, e)
}
})
}
}
func TestPerformerQueryForAutoTag(t *testing.T) {
withTxn(func(ctx context.Context) error {
tqb := db.Performer

View File

@@ -864,6 +864,12 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF
stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f)
}
}))
query.handleCriterion(ctx, &stashIDCriterionHandler{
c: sceneFilter.StashIDEndpoint,
stashIDRepository: qb.stashIDRepository(),
stashIDTableAs: "scene_stash_ids",
parentIDCol: "scenes.id",
})
query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable))
query.handleCriterion(ctx, intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable))

View File

@@ -2091,6 +2091,104 @@ func sceneQueryQ(ctx context.Context, t *testing.T, sqb models.SceneReader, q st
assert.Len(t, scenes, totalScenes)
}
func TestSceneQuery(t *testing.T) {
var (
endpoint = sceneStashID(sceneIdxWithGallery).Endpoint
stashID = sceneStashID(sceneIdxWithGallery).StashID
)
tests := []struct {
name string
findFilter *models.FindFilterType
filter *models.SceneFilterType
includeIdxs []int
excludeIdxs []int
wantErr bool
}{
{
"stash id with endpoint",
nil,
&models.SceneFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
StashID: &stashID,
Modifier: models.CriterionModifierEquals,
},
},
[]int{sceneIdxWithGallery},
nil,
false,
},
{
"exclude stash id with endpoint",
nil,
&models.SceneFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
StashID: &stashID,
Modifier: models.CriterionModifierNotEquals,
},
},
nil,
[]int{sceneIdxWithGallery},
false,
},
{
"null stash id with endpoint",
nil,
&models.SceneFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierIsNull,
},
},
nil,
[]int{sceneIdxWithGallery},
false,
},
{
"not null stash id with endpoint",
nil,
&models.SceneFilterType{
StashIDEndpoint: &models.StashIDCriterionInput{
Endpoint: &endpoint,
Modifier: models.CriterionModifierNotNull,
},
},
[]int{sceneIdxWithGallery},
nil,
false,
},
}
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
results, err := db.Scene.Query(ctx, models.SceneQueryOptions{
SceneFilter: tt.filter,
QueryOptions: models.QueryOptions{
FindFilter: tt.findFilter,
},
})
if (err != nil) != tt.wantErr {
t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr)
return
}
include := indexesToIDs(performerIDs, tt.includeIdxs)
exclude := indexesToIDs(performerIDs, tt.excludeIdxs)
for _, i := range include {
assert.Contains(results.IDs, i)
}
for _, e := range exclude {
assert.NotContains(results.IDs, e)
}
})
}
}
func TestSceneQueryPath(t *testing.T) {
const (
sceneIdx = 1

View File

@@ -245,6 +245,12 @@ func (qb *studioQueryBuilder) makeFilter(ctx context.Context, studioFilter *mode
stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f)
}
}))
query.handleCriterion(ctx, &stashIDCriterionHandler{
c: studioFilter.StashIDEndpoint,
stashIDRepository: qb.stashIDRepository(),
stashIDTableAs: "studio_stash_ids",
parentIDCol: "studios.id",
})
query.handleCriterion(ctx, studioIsMissingCriterionHandler(qb, studioFilter.IsMissing))
query.handleCriterion(ctx, studioSceneCountCriterionHandler(qb, studioFilter.SceneCount))

View File

@@ -22,6 +22,7 @@ import { FormattedMessage, useIntl } from "react-intl";
import {
criterionIsHierarchicalLabelValue,
criterionIsNumberValue,
criterionIsStashIDValue,
criterionIsDateValue,
criterionIsTimestampValue,
CriterionType,
@@ -36,6 +37,8 @@ import { DateFilter } from "./Filters/DateFilter";
import { TimestampFilter } from "./Filters/TimestampFilter";
import { CountryCriterion } from "src/models/list-filter/criteria/country";
import { CountrySelect } from "../Shared";
import { StashIDCriterion } from "src/models/list-filter/criteria/stash-ids";
import { StashIDFilter } from "./Filters/StashIDFilter";
import { ConfigurationContext } from "src/hooks/Config";
import { RatingCriterion } from "../../models/list-filter/criteria/rating";
import { RatingFilter } from "./Filters/RatingFilter";
@@ -134,6 +137,16 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
}
function renderSelect() {
// always show stashID filter
if (criterion instanceof StashIDCriterion) {
return (
<StashIDFilter
criterion={criterion}
onValueChanged={onValueChanged}
/>
);
}
// Hide the value select if the modifier is "IsNull" or "NotNull"
if (
criterion.modifier === CriterionModifier.IsNull ||
@@ -162,6 +175,7 @@ export const AddFilterDialog: React.FC<IAddFilterProps> = ({
options &&
!criterionIsHierarchicalLabelValue(criterion.value) &&
!criterionIsNumberValue(criterion.value) &&
!criterionIsStashIDValue(criterion.value) &&
!criterionIsDateValue(criterion.value) &&
!criterionIsTimestampValue(criterion.value) &&
!Array.isArray(criterion.value)

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Form } from "react-bootstrap";
import { useIntl } from "react-intl";
import { IStashIDValue } from "../../../models/list-filter/types";
import { Criterion } from "../../../models/list-filter/criteria/criterion";
import { CriterionModifier } from "src/core/generated-graphql";
interface IStashIDFilterProps {
criterion: Criterion<IStashIDValue>;
onValueChanged: (value: IStashIDValue) => void;
}
export const StashIDFilter: React.FC<IStashIDFilterProps> = ({
criterion,
onValueChanged,
}) => {
const intl = useIntl();
function onEndpointChanged(event: React.ChangeEvent<HTMLInputElement>) {
onValueChanged({
endpoint: event.target.value,
stashID: criterion.value.stashID,
});
}
function onStashIDChanged(event: React.ChangeEvent<HTMLInputElement>) {
onValueChanged({
stashID: event.target.value,
endpoint: criterion.value.endpoint,
});
}
return (
<div>
<Form.Group>
<Form.Control
className="btn-secondary"
onBlur={onEndpointChanged}
defaultValue={criterion.value ? criterion.value.endpoint : ""}
placeholder={intl.formatMessage({ id: "stash_id_endpoint" })}
/>
</Form.Group>
{criterion.modifier !== CriterionModifier.IsNull &&
criterion.modifier !== CriterionModifier.NotNull && (
<Form.Group>
<Form.Control
className="btn-secondary"
onBlur={onStashIDChanged}
defaultValue={criterion.value ? criterion.value.stashID : ""}
placeholder={intl.formatMessage({ id: "stash_id" })}
/>
</Form.Group>
)}
</div>
);
};

View File

@@ -1,4 +1,5 @@
### ✨ New Features
* Added support for filtering stash ids by endpoint. ([#3005](https://github.com/stashapp/stash/pull/3005))
* Added custom javascript option. ([#3132](https://github.com/stashapp/stash/pull/3132))
* Added ability to select rating system in the Interface settings, allowing 5 stars with full-, half- or quarter-stars, or numeric score out of 10 with one decimal point. ([#2830](https://github.com/stashapp/stash/pull/2830))
* Added filter criteria for Birthdate, Death Date, Date, Created At and Updated At fields. ([#2834](https://github.com/stashapp/stash/pull/2834))

View File

@@ -1060,6 +1060,7 @@
"submit_update": "Already exists in {endpoint_name}"
},
"statistics": "Statistics",
"stash_id_endpoint": "Stash ID Endpoint",
"stats": {
"image_size": "Images size",
"scenes_duration": "Scenes duration",

View File

@@ -19,6 +19,7 @@ import {
ILabeledValue,
INumberValue,
IOptionType,
IStashIDValue,
IDateValue,
ITimestampValue,
} from "../types";
@@ -29,6 +30,7 @@ export type CriterionValue =
| ILabeledId[]
| IHierarchicalLabelValue
| INumberValue
| IStashIDValue
| IDateValue
| ITimestampValue;

View File

@@ -50,6 +50,7 @@ import { DuplicatedCriterion, PhashCriterionOption } from "./phash";
import { CaptionCriterion } from "./captions";
import { RatingCriterion } from "./rating";
import { CountryCriterion } from "./country";
import { StashIDCriterion } from "./stash-ids";
import * as GQL from "src/core/generated-graphql";
import { IUIConfig } from "src/core/config";
import { defaultRatingSystemOptions } from "src/utils/rating";
@@ -169,6 +170,10 @@ export function makeCriteria(
return new NumberCriterion(
new NumberCriterionOption("height", "height_cm", type)
);
// stash_id is deprecated
case "stash_id":
case "stash_id_endpoint":
return new StashIDCriterion();
case "ethnicity":
case "hair_color":
case "eye_color":
@@ -179,7 +184,6 @@ export function makeCriteria(
case "piercings":
case "aliases":
case "url":
case "stash_id":
case "details":
case "title":
case "director":

View File

@@ -0,0 +1,106 @@
/* eslint @typescript-eslint/no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */
import { IntlShape } from "react-intl";
import {
CriterionModifier,
StashIdCriterionInput,
} from "src/core/generated-graphql";
import { IStashIDValue } from "../types";
import { Criterion, CriterionOption } from "./criterion";
export const StashIDCriterionOption = new CriterionOption({
messageID: "stash_id",
type: "stash_id_endpoint",
parameterName: "stash_id_endpoint",
modifierOptions: [
CriterionModifier.Equals,
CriterionModifier.NotEquals,
CriterionModifier.IsNull,
CriterionModifier.NotNull,
],
});
export class StashIDCriterion extends Criterion<IStashIDValue> {
constructor() {
super(StashIDCriterionOption, {
endpoint: "",
stashID: "",
});
}
public get value(): IStashIDValue {
return this._value;
}
public set value(newValue: string | IStashIDValue) {
// backwards compatibility - if this.value is a string, use that as stash_id
if (typeof newValue !== "object") {
this._value = {
endpoint: "",
stashID: newValue,
};
} else {
this._value = newValue;
}
}
protected toCriterionInput(): StashIdCriterionInput {
return {
endpoint: this.value.endpoint,
stash_id: this.value.stashID,
modifier: this.modifier,
};
}
public getLabel(intl: IntlShape): string {
const modifierString = Criterion.getModifierLabel(intl, this.modifier);
let valueString = "";
if (
this.modifier !== CriterionModifier.IsNull &&
this.modifier !== CriterionModifier.NotNull
) {
valueString = this.getLabelValue(intl);
} else if (this.value.endpoint) {
valueString = "(" + this.value.endpoint + ")";
}
return intl.formatMessage(
{ id: "criterion_modifier.format_string" },
{
criterion: intl.formatMessage({ id: this.criterionOption.messageID }),
modifierString,
valueString,
}
);
}
public getLabelValue(_intl: IntlShape) {
let ret = this.value.stashID;
if (this.value.endpoint) {
ret += " (" + this.value.endpoint + ")";
}
return ret;
}
public toJSON() {
let encodedCriterion;
if (
(this.modifier === CriterionModifier.IsNull ||
this.modifier === CriterionModifier.NotNull) &&
!this.value.endpoint
) {
encodedCriterion = {
type: this.criterionOption.type,
modifier: this.modifier,
};
} else {
encodedCriterion = {
type: this.criterionOption.type,
value: this.value,
modifier: this.modifier,
};
}
return JSON.stringify(encodedCriterion);
}
}

View File

@@ -11,6 +11,7 @@ import {
import { FavoriteCriterionOption } from "./criteria/favorite";
import { GenderCriterionOption } from "./criteria/gender";
import { PerformerIsMissingCriterionOption } from "./criteria/is-missing";
import { StashIDCriterionOption } from "./criteria/stash-ids";
import { StudiosCriterionOption } from "./criteria/studios";
import { TagsCriterionOption } from "./criteria/tags";
import { ListFilterOptions } from "./filter-options";
@@ -67,7 +68,6 @@ const stringCriteria: CriterionType[] = [
"tattoos",
"piercings",
"aliases",
"stash_id",
];
const criterionOptions = [
@@ -76,6 +76,7 @@ const criterionOptions = [
PerformerIsMissingCriterionOption,
TagsCriterionOption,
StudiosCriterionOption,
StashIDCriterionOption,
createStringCriterionOption("url"),
new NullNumberCriterionOption("rating", "rating100"),
createMandatoryNumberCriterionOption("tag_count"),

View File

@@ -26,6 +26,7 @@ import {
} from "./criteria/phash";
import { PerformerFavoriteCriterionOption } from "./criteria/favorite";
import { CaptionsCriterionOption } from "./criteria/captions";
import { StashIDCriterionOption } from "./criteria/stash-ids";
const defaultSortBy = "date";
const sortByOptions = [
@@ -82,7 +83,7 @@ const criterionOptions = [
StudiosCriterionOption,
MoviesCriterionOption,
createStringCriterionOption("url"),
createStringCriterionOption("stash_id"),
StashIDCriterionOption,
InteractiveCriterionOption,
CaptionsCriterionOption,
createMandatoryNumberCriterionOption("interactive_speed"),

View File

@@ -7,6 +7,7 @@ import {
createMandatoryTimestampCriterionOption,
} from "./criteria/criterion";
import { StudioIsMissingCriterionOption } from "./criteria/is-missing";
import { StashIDCriterionOption } from "./criteria/stash-ids";
import { ParentStudiosCriterionOption } from "./criteria/studios";
import { ListFilterOptions } from "./filter-options";
import { DisplayMode } from "./types";
@@ -41,7 +42,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("image_count"),
createMandatoryNumberCriterionOption("gallery_count"),
createStringCriterionOption("url"),
createStringCriterionOption("stash_id"),
StashIDCriterionOption,
createStringCriterionOption("aliases"),
createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"),

View File

@@ -33,6 +33,11 @@ export interface IPHashDuplicationValue {
distance?: number; // currently not implemented
}
export interface IStashIDValue {
endpoint: string;
stashID: string;
}
export interface IDateValue {
value: string;
value2: string | undefined;
@@ -57,6 +62,13 @@ export function criterionIsNumberValue(
return typeof value === "object" && "value" in value && "value2" in value;
}
export function criterionIsStashIDValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any
): value is IStashIDValue {
return typeof value === "object" && "endpoint" in value && "stashID" in value;
}
export function criterionIsDateValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any
@@ -151,6 +163,7 @@ export type CriterionType =
| "duplicated"
| "ignore_auto_tag"
| "file_count"
| "stash_id_endpoint"
| "date"
| "created_at"
| "updated_at"