diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index f0f84efda8c..23396a98ffd 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -193,6 +193,8 @@ input SceneMarkerFilterType { performers: MultiCriterionInput "Filter to only include scene markers from these scenes" scenes: MultiCriterionInput + "Filter by duration (in seconds)" + duration: FloatCriterionInput "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" diff --git a/pkg/models/scene_marker.go b/pkg/models/scene_marker.go index 8c4598a6df4..82f9faa1918 100644 --- a/pkg/models/scene_marker.go +++ b/pkg/models/scene_marker.go @@ -11,6 +11,8 @@ type SceneMarkerFilterType struct { Performers *MultiCriterionInput `json:"performers"` // Filter to only include scene markers from these scenes Scenes *MultiCriterionInput `json:"scenes"` + // Filter by duration (in seconds) + Duration *FloatCriterionInput `json:"duration"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 8b2306eab4b..ed98d0ef74a 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -367,6 +367,7 @@ var sceneMarkerSortOptions = sortOptions{ "scenes_updated_at", "seconds", "updated_at", + "duration", } func (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter *models.FindFilterType) error { @@ -386,6 +387,9 @@ func (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter * case "title": query.join(tagTable, "", "scene_markers.primary_tag_id = tags.id") query.sortAndPagination += " ORDER BY COALESCE(NULLIF(scene_markers.title,''), tags.name) COLLATE NATURAL_CI " + direction + case "duration": + sort = "(scene_markers.end_seconds - scene_markers.seconds)" + query.sortAndPagination += getSort(sort, direction, sceneMarkerTable) default: query.sortAndPagination += getSort(sort, direction, sceneMarkerTable) } diff --git a/pkg/sqlite/scene_marker_filter.go b/pkg/sqlite/scene_marker_filter.go index d5e044e85a7..34fa0f39b36 100644 --- a/pkg/sqlite/scene_marker_filter.go +++ b/pkg/sqlite/scene_marker_filter.go @@ -41,6 +41,7 @@ func (qb *sceneMarkerFilterHandler) criterionHandler() criterionHandler { qb.sceneTagsCriterionHandler(sceneMarkerFilter.SceneTags), qb.performersCriterionHandler(sceneMarkerFilter.Performers), qb.scenesCriterionHandler(sceneMarkerFilter.Scenes), + floatCriterionHandler(sceneMarkerFilter.Duration, "COALESCE(scene_markers.end_seconds - scene_markers.seconds, NULL)", nil), ×tampCriterionHandler{sceneMarkerFilter.CreatedAt, "scene_markers.created_at", nil}, ×tampCriterionHandler{sceneMarkerFilter.UpdatedAt, "scene_markers.updated_at", nil}, &dateCriterionHandler{sceneMarkerFilter.SceneDate, "scenes.date", qb.joinScenes}, diff --git a/pkg/sqlite/scene_marker_test.go b/pkg/sqlite/scene_marker_test.go index ce8f4d3ad6b..64893b3a67f 100644 --- a/pkg/sqlite/scene_marker_test.go +++ b/pkg/sqlite/scene_marker_test.go @@ -391,6 +391,116 @@ func TestMarkerQuerySceneTags(t *testing.T) { }) } +func markersToIDs(i []*models.SceneMarker) []int { + ret := make([]int, len(i)) + for i, v := range i { + ret[i] = v.ID + } + + return ret +} + +func TestMarkerQueryDuration(t *testing.T) { + type test struct { + name string + markerFilter *models.SceneMarkerFilterType + include []int + exclude []int + } + + cases := []test{ + { + "is null", + &models.SceneMarkerFilterType{ + Duration: &models.FloatCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, + }, + []int{markerIdxWithScene}, + []int{markerIdxWithDuration}, + }, + { + "not null", + &models.SceneMarkerFilterType{ + Duration: &models.FloatCriterionInput{ + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{markerIdxWithDuration}, + []int{markerIdxWithScene}, + }, + { + "equals", + &models.SceneMarkerFilterType{ + Duration: &models.FloatCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: markerIdxWithDuration, + }, + }, + []int{markerIdxWithDuration}, + []int{markerIdx2WithDuration, markerIdxWithScene}, + }, + { + "not equals", + &models.SceneMarkerFilterType{ + Duration: &models.FloatCriterionInput{ + Modifier: models.CriterionModifierNotEquals, + Value: markerIdx2WithDuration, + }, + }, + []int{markerIdxWithDuration}, + []int{markerIdx2WithDuration, markerIdxWithScene}, + }, + { + "greater than", + &models.SceneMarkerFilterType{ + Duration: &models.FloatCriterionInput{ + Modifier: models.CriterionModifierGreaterThan, + Value: markerIdxWithDuration, + }, + }, + []int{markerIdx2WithDuration}, + []int{markerIdxWithDuration, markerIdxWithScene}, + }, + { + "less than", + &models.SceneMarkerFilterType{ + Duration: &models.FloatCriterionInput{ + Modifier: models.CriterionModifierLessThan, + Value: markerIdx2WithDuration, + }, + }, + []int{markerIdxWithDuration}, + []int{markerIdx2WithDuration, markerIdxWithScene}, + }, + } + + qb := db.SceneMarker + + for _, tt := range cases { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + got, _, err := qb.Query(ctx, tt.markerFilter, nil) + if err != nil { + t.Errorf("SceneMarkerStore.Query() error = %v", err) + return + } + + ids := markersToIDs(got) + include := indexesToIDs(markerIDs, tt.include) + exclude := indexesToIDs(markerIDs, tt.exclude) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } + +} + func queryMarkers(ctx context.Context, t *testing.T, sqb models.SceneMarkerReader, markerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) []*models.SceneMarker { t.Helper() result, _, err := sqb.Query(ctx, markerFilter, findFilter) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 1c3f914d3ba..b63b6a04a2c 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -276,6 +276,8 @@ const ( markerIdxWithScene = iota markerIdxWithTag markerIdxWithSceneTag + markerIdxWithDuration + markerIdx2WithDuration totalMarkers ) @@ -1754,10 +1756,20 @@ func createStudios(ctx context.Context, n int, o int) error { return nil } +func getMarkerEndSeconds(index int) *float64 { + if index != markerIdxWithDuration && index != markerIdx2WithDuration { + return nil + } + ret := float64(index) + return &ret +} + func createMarker(ctx context.Context, mqb models.SceneMarkerReaderWriter, markerSpec markerSpec) error { + markerIdx := len(markerIDs) marker := models.SceneMarker{ SceneID: sceneIDs[markerSpec.sceneIdx], PrimaryTagID: tagIDs[markerSpec.primaryTagIdx], + EndSeconds: getMarkerEndSeconds(markerIdx), } err := mqb.Create(ctx, &marker) 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 d950073be4c..4fbf7c03b68 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -637,7 +637,11 @@ export function createNumberCriterionOption( } export class NullNumberCriterionOption extends CriterionOption { - constructor(messageID: string, value: CriterionType) { + constructor( + messageID: string, + value: CriterionType, + makeCriterion?: () => Criterion + ) { super({ messageID, type: value, @@ -653,7 +657,9 @@ export class NullNumberCriterionOption extends CriterionOption { ], defaultModifier: CriterionModifier.Equals, inputType: "number", - makeCriterion: () => new NumberCriterion(this), + makeCriterion: makeCriterion + ? makeCriterion + : () => new NumberCriterion(this), }); } } @@ -780,6 +786,19 @@ export function createDurationCriterionOption( return new DurationCriterionOption(messageID ?? value, value); } +export class NullDurationCriterionOption extends NullNumberCriterionOption { + constructor(messageID: string, value: CriterionType) { + super(messageID, value, () => new DurationCriterion(this)); + } +} + +export function createNullDurationCriterionOption( + value: CriterionType, + messageID?: string +) { + return new NullDurationCriterionOption(messageID ?? value, value); +} + export class DurationCriterion extends Criterion { constructor(type: CriterionOption) { super(type, { value: undefined, value2: undefined }); diff --git a/ui/v2.5/src/models/list-filter/scene-markers.ts b/ui/v2.5/src/models/list-filter/scene-markers.ts index 7f6e555ccf3..a70cd16291e 100644 --- a/ui/v2.5/src/models/list-filter/scene-markers.ts +++ b/ui/v2.5/src/models/list-filter/scene-markers.ts @@ -6,10 +6,12 @@ import { DisplayMode } from "./types"; import { createDateCriterionOption, createMandatoryTimestampCriterionOption, + createNullDurationCriterionOption, } from "./criteria/criterion"; const defaultSortBy = "title"; const sortByOptions = [ + "duration", "title", "seconds", "scene_id", @@ -22,6 +24,7 @@ const criterionOptions = [ MarkersScenesCriterionOption, SceneTagsCriterionOption, PerformersCriterionOption, + createNullDurationCriterionOption("duration"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), createDateCriterionOption("scene_date"),