Add scene duration filter (#313)

* Add scene duration filter
This commit is contained in:
WithoutPants
2020-01-14 03:43:14 +11:00
committed by Leopere
parent 600862c0bb
commit cf96cae4c8
8 changed files with 167 additions and 61 deletions

View File

@@ -64,6 +64,8 @@ input SceneFilterType {
rating: IntCriterionInput rating: IntCriterionInput
"""Filter by resolution""" """Filter by resolution"""
resolution: ResolutionEnum resolution: ResolutionEnum
"""Filter by duration (in seconds)"""
duration: IntCriterionInput
"""Filter to only include scenes which have markers. `true` or `false`""" """Filter to only include scenes which have markers. `true` or `false`"""
has_markers: String has_markers: String
"""Filter to only include scenes missing this property""" """Filter to only include scenes missing this property"""

View File

@@ -171,13 +171,19 @@ func (qb *SceneQueryBuilder) Query(sceneFilter *SceneFilterType, findFilter *Fin
} }
if rating := sceneFilter.Rating; rating != nil { if rating := sceneFilter.Rating; rating != nil {
clause, count := getIntCriterionWhereClause("rating", *sceneFilter.Rating) clause, count := getIntCriterionWhereClause("scenes.rating", *sceneFilter.Rating)
whereClauses = append(whereClauses, clause) whereClauses = append(whereClauses, clause)
if count == 1 { if count == 1 {
args = append(args, sceneFilter.Rating.Value) args = append(args, sceneFilter.Rating.Value)
} }
} }
if durationFilter := sceneFilter.Duration; durationFilter != nil {
clause, thisArgs := getDurationWhereClause(*durationFilter)
whereClauses = append(whereClauses, clause)
args = append(args, thisArgs...)
}
if resolutionFilter := sceneFilter.Resolution; resolutionFilter != nil { if resolutionFilter := sceneFilter.Resolution; resolutionFilter != nil {
if resolution := resolutionFilter.String(); resolutionFilter.IsValid() { if resolution := resolutionFilter.String(); resolutionFilter.IsValid() {
switch resolution { switch resolution {
@@ -270,6 +276,34 @@ func appendClause(clauses []string, clause string) []string {
return clauses return clauses
} }
func getDurationWhereClause(durationFilter IntCriterionInput) (string, []interface{}) {
// special case for duration. We accept duration as seconds as int but the
// field is floating point. Change the equals filter to return a range
// between x and x + 1
// likewise, not equals needs to be duration < x OR duration >= x
var clause string
args := []interface{}{}
value := durationFilter.Value
if durationFilter.Modifier == CriterionModifierEquals {
clause = "scenes.duration >= ? AND scenes.duration < ?"
args = append(args, value)
args = append(args, value+1)
} else if durationFilter.Modifier == CriterionModifierNotEquals {
clause = "(scenes.duration < ? OR scenes.duration >= ?)"
args = append(args, value)
args = append(args, value+1)
} else {
var count int
clause, count = getIntCriterionWhereClause("scenes.duration", durationFilter)
if count == 1 {
args = append(args, value)
}
}
return clause, args
}
// returns where clause and having clause // returns where clause and having clause
func getMultiCriterionClause(table string, joinTable string, joinTableField string, criterion *MultiCriterionInput) (string, string) { func getMultiCriterionClause(table string, joinTable string, joinTableField string, criterion *MultiCriterionInput) (string, string) {
whereClause := "" whereClause := ""

View File

@@ -1,6 +1,6 @@
import React, { FunctionComponent, useState, useEffect } from "react"; import React, { FunctionComponent, useState, useEffect } from "react";
import { InputGroup, ButtonGroup, Button, IInputGroupProps, HTMLInputProps, ControlGroup } from "@blueprintjs/core"; import { InputGroup, ButtonGroup, Button, IInputGroupProps, HTMLInputProps, ControlGroup } from "@blueprintjs/core";
import { TextUtils } from "../../utils/text"; import { DurationUtils } from "../../utils/duration";
import { FIXED, NUMERIC_INPUT } from "@blueprintjs/core/lib/esm/common/classes"; import { FIXED, NUMERIC_INPUT } from "@blueprintjs/core/lib/esm/common/classes";
interface IProps { interface IProps {
@@ -11,65 +11,20 @@ interface IProps {
} }
export const DurationInput: FunctionComponent<HTMLInputProps & IProps> = (props: IProps) => { export const DurationInput: FunctionComponent<HTMLInputProps & IProps> = (props: IProps) => {
const [value, setValue] = useState<string>(secondsToString(props.numericValue)); const [value, setValue] = useState<string>(DurationUtils.secondsToString(props.numericValue));
useEffect(() => { useEffect(() => {
setValue(secondsToString(props.numericValue)); setValue(DurationUtils.secondsToString(props.numericValue));
}, [props.numericValue]); }, [props.numericValue]);
function secondsToString(seconds : number) {
let ret = TextUtils.secondsToTimestamp(seconds);
if (ret.startsWith("00:")) {
ret = ret.substr(3);
if (ret.startsWith("0")) {
ret = ret.substr(1);
}
}
return ret;
}
function stringToSeconds(v : string) {
if (!v) {
return 0;
}
let splits = v.split(":");
if (splits.length > 3) {
return 0;
}
let seconds = 0;
let factor = 1;
while(splits.length > 0) {
let thisSplit = splits.pop();
if (thisSplit == undefined) {
return 0;
}
let thisInt = parseInt(thisSplit, 10);
if (isNaN(thisInt)) {
return 0;
}
seconds += factor * thisInt;
factor *= 60;
}
return seconds;
}
function increment() { function increment() {
let seconds = stringToSeconds(value); let seconds = DurationUtils.stringToSeconds(value);
seconds += 1; seconds += 1;
props.onValueChange(seconds); props.onValueChange(seconds);
} }
function decrement() { function decrement() {
let seconds = stringToSeconds(value); let seconds = DurationUtils.stringToSeconds(value);
seconds -= 1; seconds -= 1;
props.onValueChange(seconds); props.onValueChange(seconds);
} }
@@ -117,7 +72,7 @@ export const DurationInput: FunctionComponent<HTMLInputProps & IProps> = (props:
disabled={props.disabled} disabled={props.disabled}
value={value} value={value}
onChange={(e : any) => setValue(e.target.value)} onChange={(e : any) => setValue(e.target.value)}
onBlur={() => props.onValueChange(stringToSeconds(value))} onBlur={() => props.onValueChange(DurationUtils.stringToSeconds(value))}
placeholder="hh:mm:ss" placeholder="hh:mm:ss"
rightElement={maybeRenderReset()} rightElement={maybeRenderReset()}
/> />

View File

@@ -11,7 +11,7 @@ import _ from "lodash";
import React, { FunctionComponent, useEffect, useRef, useState } from "react"; import React, { FunctionComponent, useEffect, useRef, useState } from "react";
import { isArray } from "util"; import { isArray } from "util";
import { CriterionModifier } from "../../core/generated-graphql"; import { CriterionModifier } from "../../core/generated-graphql";
import { Criterion, CriterionType } from "../../models/list-filter/criteria/criterion"; import { Criterion, CriterionType, DurationCriterion } from "../../models/list-filter/criteria/criterion";
import { NoneCriterion } from "../../models/list-filter/criteria/none"; import { NoneCriterion } from "../../models/list-filter/criteria/none";
import { PerformersCriterion } from "../../models/list-filter/criteria/performers"; import { PerformersCriterion } from "../../models/list-filter/criteria/performers";
import { StudiosCriterion } from "../../models/list-filter/criteria/studios"; import { StudiosCriterion } from "../../models/list-filter/criteria/studios";
@@ -19,6 +19,7 @@ import { TagsCriterion } from "../../models/list-filter/criteria/tags";
import { makeCriteria } from "../../models/list-filter/criteria/utils"; import { makeCriteria } from "../../models/list-filter/criteria/utils";
import { ListFilterModel } from "../../models/list-filter/filter"; import { ListFilterModel } from "../../models/list-filter/filter";
import { FilterMultiSelect } from "../select/FilterMultiSelect"; import { FilterMultiSelect } from "../select/FilterMultiSelect";
import { DurationInput } from "../Shared/DurationInput";
interface IAddFilterProps { interface IAddFilterProps {
onAddCriterion: (criterion: Criterion, oldId?: string) => void; onAddCriterion: (criterion: Criterion, oldId?: string) => void;
@@ -64,6 +65,11 @@ export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterP
valueStage.current = event.target.value; valueStage.current = event.target.value;
} }
function onChangedDuration(valueAsNumber: number) {
valueStage.current = valueAsNumber;
onBlurInput();
}
function onBlurInput() { function onBlurInput() {
const newCriterion = _.cloneDeep(criterion); const newCriterion = _.cloneDeep(criterion);
newCriterion.value = valueStage.current; newCriterion.value = valueStage.current;
@@ -148,6 +154,14 @@ export const AddFilter: FunctionComponent<IAddFilterProps> = (props: IAddFilterP
defaultValue={criterion.value} defaultValue={criterion.value}
/> />
); );
} else if (criterion instanceof DurationCriterion) {
// render duration control
return (
<DurationInput
numericValue={criterion.value ? criterion.value : 0}
onValueChange={onChangedDuration}
/>
)
} else { } else {
return ( return (
<InputGroup <InputGroup

View File

@@ -1,11 +1,13 @@
import { isArray } from "util"; import { isArray } from "util";
import { CriterionModifier } from "../../../core/generated-graphql"; import { CriterionModifier } from "../../../core/generated-graphql";
import { ILabeledId, ILabeledValue } from "../types"; import { ILabeledId, ILabeledValue } from "../types";
import { DurationUtils } from "../../../utils/duration";
export type CriterionType = export type CriterionType =
"none" | "none" |
"rating" | "rating" |
"resolution" | "resolution" |
"duration" |
"favorite" | "favorite" |
"hasMarkers" | "hasMarkers" |
"isMissing" | "isMissing" |
@@ -32,6 +34,7 @@ export abstract class Criterion<Option = any, Value = any> {
case "none": return "None"; case "none": return "None";
case "rating": return "Rating"; case "rating": return "Rating";
case "resolution": return "Resolution"; case "resolution": return "Resolution";
case "duration": return "Duration";
case "favorite": return "Favorite"; case "favorite": return "Favorite";
case "hasMarkers": return "Has Markers"; case "hasMarkers": return "Has Markers";
case "isMissing": return "Is Missing"; case "isMissing": return "Is Missing";
@@ -91,10 +94,18 @@ export abstract class Criterion<Option = any, Value = any> {
default: modifierString = ""; default: modifierString = "";
} }
let valueString = "";
if (this.modifier !== CriterionModifier.IsNull && this.modifier !== CriterionModifier.NotNull) {
valueString = this.getLabelValue();
}
return `${Criterion.getLabel(this.type)} ${modifierString} ${valueString}`;
}
public getLabelValue() {
let valueString: string; let valueString: string;
if (this.modifier === CriterionModifier.IsNull || this.modifier === CriterionModifier.NotNull) { if (isArray(this.value) && this.value.length > 0) {
valueString = "";
} else if (isArray(this.value) && this.value.length > 0) {
let items = this.value; let items = this.value;
if ((this.value as ILabeledId[])[0].label) { if ((this.value as ILabeledId[])[0].label) {
items = this.value.map((item) => item.label) as any; items = this.value.map((item) => item.label) as any;
@@ -106,7 +117,7 @@ export abstract class Criterion<Option = any, Value = any> {
valueString = this.value.toString(); valueString = this.value.toString();
} }
return `${Criterion.getLabel(this.type)} ${modifierString} ${valueString}`; return valueString;
} }
public getId(): string { public getId(): string {
@@ -195,3 +206,34 @@ export class NumberCriterion extends Criterion<number, number> {
} }
} }
} }
export class DurationCriterion extends Criterion<number, number> {
public type: CriterionType;
public parameterName: string;
public modifier = CriterionModifier.Equals;
public modifierOptions = [
Criterion.getModifierOption(CriterionModifier.Equals),
Criterion.getModifierOption(CriterionModifier.NotEquals),
Criterion.getModifierOption(CriterionModifier.GreaterThan),
Criterion.getModifierOption(CriterionModifier.LessThan),
];
public options: number[] | undefined;
public value: number = 0;
constructor(type : CriterionType, parameterName?: string, options? : number[]) {
super();
this.type = type;
this.options = options;
if (!!parameterName) {
this.parameterName = parameterName;
} else {
this.parameterName = type;
}
}
public getLabelValue() {
return DurationUtils.secondsToString(this.value);
}
}

View File

@@ -1,7 +1,7 @@
import { import {
CriterionModifier, CriterionModifier,
} from "../../../core/generated-graphql"; } from "../../../core/generated-graphql";
import { Criterion, CriterionType, StringCriterion, NumberCriterion } from "./criterion"; import { Criterion, CriterionType, StringCriterion, NumberCriterion, DurationCriterion } from "./criterion";
import { FavoriteCriterion } from "./favorite"; import { FavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers"; import { HasMarkersCriterion } from "./has-markers";
import { IsMissingCriterion } from "./is-missing"; import { IsMissingCriterion } from "./is-missing";
@@ -17,6 +17,7 @@ export function makeCriteria(type: CriterionType = "none") {
case "none": return new NoneCriterion(); case "none": return new NoneCriterion();
case "rating": return new RatingCriterion(); case "rating": return new RatingCriterion();
case "resolution": return new ResolutionCriterion(); case "resolution": return new ResolutionCriterion();
case "duration": return new DurationCriterion(type, type);
case "favorite": return new FavoriteCriterion(); case "favorite": return new FavoriteCriterion();
case "hasMarkers": return new HasMarkersCriterion(); case "hasMarkers": return new HasMarkersCriterion();
case "isMissing": return new IsMissingCriterion(); case "isMissing": return new IsMissingCriterion();
@@ -25,7 +26,7 @@ export function makeCriteria(type: CriterionType = "none") {
case "performers": return new PerformersCriterion(); case "performers": return new PerformersCriterion();
case "studios": return new StudiosCriterion(); case "studios": return new StudiosCriterion();
case "birth_year": case "birth_year": return new NumberCriterion(type, type);
case "age": case "age":
var ret = new NumberCriterion(type, type); var ret = new NumberCriterion(type, type);
// null/not null doesn't make sense for these criteria // null/not null doesn't make sense for these criteria

View File

@@ -7,7 +7,7 @@ import {
SceneMarkerFilterType, SceneMarkerFilterType,
SortDirectionEnum, SortDirectionEnum,
} from "../../core/generated-graphql"; } from "../../core/generated-graphql";
import { Criterion, ICriterionOption, CriterionType, CriterionOption, NumberCriterion, StringCriterion } from "./criteria/criterion"; import { Criterion, ICriterionOption, CriterionType, CriterionOption, NumberCriterion, StringCriterion, DurationCriterion } from "./criteria/criterion";
import { FavoriteCriterion, FavoriteCriterionOption } from "./criteria/favorite"; import { FavoriteCriterion, FavoriteCriterionOption } from "./criteria/favorite";
import { HasMarkersCriterion, HasMarkersCriterionOption } from "./criteria/has-markers"; import { HasMarkersCriterion, HasMarkersCriterionOption } from "./criteria/has-markers";
import { IsMissingCriterion, IsMissingCriterionOption } from "./criteria/is-missing"; import { IsMissingCriterion, IsMissingCriterionOption } from "./criteria/is-missing";
@@ -47,6 +47,10 @@ export class ListFilterModel {
public criteria: Array<Criterion<any, any>> = []; public criteria: Array<Criterion<any, any>> = [];
public totalCount: number = 0; public totalCount: number = 0;
private static createCriterionOption(criterion: CriterionType) {
return new CriterionOption(Criterion.getLabel(criterion), criterion);
}
public constructor(filterMode: FilterMode) { public constructor(filterMode: FilterMode) {
switch (filterMode) { switch (filterMode) {
case FilterMode.Scenes: case FilterMode.Scenes:
@@ -61,6 +65,7 @@ export class ListFilterModel {
new NoneCriterionOption(), new NoneCriterionOption(),
new RatingCriterionOption(), new RatingCriterionOption(),
new ResolutionCriterionOption(), new ResolutionCriterionOption(),
ListFilterModel.createCriterionOption("duration"),
new HasMarkersCriterionOption(), new HasMarkersCriterionOption(),
new IsMissingCriterionOption(), new IsMissingCriterionOption(),
new TagsCriterionOption(), new TagsCriterionOption(),
@@ -96,7 +101,7 @@ export class ListFilterModel {
]; ];
this.criterionOptions = this.criterionOptions.concat(numberCriteria.concat(stringCriteria).map((c) => { this.criterionOptions = this.criterionOptions.concat(numberCriteria.concat(stringCriteria).map((c) => {
return new CriterionOption(Criterion.getLabel(c), c); return ListFilterModel.createCriterionOption(c);
})); }));
break; break;
case FilterMode.Studios: case FilterMode.Studios:
@@ -234,6 +239,11 @@ export class ListFilterModel {
} }
break; break;
} }
case "duration": {
const durationCrit = criterion as DurationCriterion;
result.duration = { value: durationCrit.value, modifier: durationCrit.modifier }
break;
}
case "hasMarkers": case "hasMarkers":
result.has_markers = (criterion as HasMarkersCriterion).value; result.has_markers = (criterion as HasMarkersCriterion).value;
break; break;

View File

@@ -0,0 +1,48 @@
import { TextUtils } from "./text";
export class DurationUtils {
public static secondsToString(seconds : number) {
let ret = TextUtils.secondsToTimestamp(seconds);
if (ret.startsWith("00:")) {
ret = ret.substr(3);
if (ret.startsWith("0")) {
ret = ret.substr(1);
}
}
return ret;
}
public static stringToSeconds(v : string) {
if (!v) {
return 0;
}
let splits = v.split(":");
if (splits.length > 3) {
return 0;
}
let seconds = 0;
let factor = 1;
while(splits.length > 0) {
let thisSplit = splits.pop();
if (thisSplit == undefined) {
return 0;
}
let thisInt = parseInt(thisSplit, 10);
if (isNaN(thisInt)) {
return 0;
}
seconds += factor * thisInt;
factor *= 60;
}
return seconds;
}
}