Add O-counter (#334)

* Add backend support for o-counter

* Add o-counter buttons everywhere

* Put o-counter button right-aligned on tabs

* Add o-counter filter
This commit is contained in:
WithoutPants 2020-02-03 11:17:28 +11:00 committed by GitHub
parent 066295f0c9
commit f87117b0d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 325 additions and 9 deletions

View File

@ -6,6 +6,7 @@ fragment SlimSceneData on Scene {
url
date
rating
o_counter
path
file {

View File

@ -6,6 +6,7 @@ fragment SceneData on Scene {
url
date
rating
o_counter
path
file {

View File

@ -62,6 +62,18 @@ mutation ScenesUpdate($input : [SceneUpdateInput!]!) {
}
}
mutation SceneIncrementO($id: ID!) {
sceneIncrementO(id: $id)
}
mutation SceneDecrementO($id: ID!) {
sceneDecrementO(id: $id)
}
mutation SceneResetO($id: ID!) {
sceneResetO(id: $id)
}
mutation SceneDestroy($id: ID!, $delete_file: Boolean, $delete_generated : Boolean) {
sceneDestroy(input: {id: $id, delete_file: $delete_file, delete_generated: $delete_generated})
}

View File

@ -107,6 +107,13 @@ type Mutation {
sceneDestroy(input: SceneDestroyInput!): Boolean!
scenesUpdate(input: [SceneUpdateInput!]!): [Scene]
"""Increments the o-counter for a scene. Returns the new value"""
sceneIncrementO(id: ID!): Int!
"""Decrements the o-counter for a scene. Returns the new value"""
sceneDecrementO(id: ID!): Int!
"""Resets the o-counter for a scene to 0. Returns the new value"""
sceneResetO(id: ID!): Int!
sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker
sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker
sceneMarkerDestroy(id: ID!): Boolean!

View File

@ -62,6 +62,8 @@ input SceneMarkerFilterType {
input SceneFilterType {
"""Filter by rating"""
rating: IntCriterionInput
"""Filter by o-counter"""
o_counter: IntCriterionInput
"""Filter by resolution"""
resolution: ResolutionEnum
"""Filter by duration (in seconds)"""

View File

@ -26,6 +26,7 @@ type Scene {
url: String
date: String
rating: Int
o_counter: Int
path: String!
file: SceneFileType! # Resolver

View File

@ -422,3 +422,63 @@ func changeMarker(ctx context.Context, changeType int, changedMarker models.Scen
return sceneMarker, nil
}
func (r *mutationResolver) SceneIncrementO(ctx context.Context, id string) (int, error) {
sceneID, _ := strconv.Atoi(id)
tx := database.DB.MustBeginTx(ctx, nil)
qb := models.NewSceneQueryBuilder()
newVal, err := qb.IncrementOCounter(sceneID, tx)
if err != nil {
_ = tx.Rollback()
return 0, err
}
// Commit
if err := tx.Commit(); err != nil {
return 0, err
}
return newVal, nil
}
func (r *mutationResolver) SceneDecrementO(ctx context.Context, id string) (int, error) {
sceneID, _ := strconv.Atoi(id)
tx := database.DB.MustBeginTx(ctx, nil)
qb := models.NewSceneQueryBuilder()
newVal, err := qb.DecrementOCounter(sceneID, tx)
if err != nil {
_ = tx.Rollback()
return 0, err
}
// Commit
if err := tx.Commit(); err != nil {
return 0, err
}
return newVal, nil
}
func (r *mutationResolver) SceneResetO(ctx context.Context, id string) (int, error) {
sceneID, _ := strconv.Atoi(id)
tx := database.DB.MustBeginTx(ctx, nil)
qb := models.NewSceneQueryBuilder()
newVal, err := qb.ResetOCounter(sceneID, tx)
if err != nil {
_ = tx.Rollback()
return 0, err
}
// Commit
if err := tx.Commit(); err != nil {
return 0, err
}
return newVal, nil
}

View File

@ -17,7 +17,7 @@ import (
)
var DB *sqlx.DB
var appSchemaVersion uint = 2
var appSchemaVersion uint = 3
const sqlite3Driver = "sqlite3_regexp"

View File

@ -0,0 +1 @@
ALTER TABLE `scenes` ADD COLUMN `o_counter` tinyint not null default 0;

View File

@ -15,6 +15,7 @@ type Scene struct {
URL sql.NullString `db:"url" json:"url"`
Date SQLiteDate `db:"date" json:"date"`
Rating sql.NullInt64 `db:"rating" json:"rating"`
OCounter int `db:"o_counter" json:"o_counter"`
Size sql.NullString `db:"size" json:"size"`
Duration sql.NullFloat64 `db:"duration" json:"duration"`
VideoCodec sql.NullString `db:"video_codec" json:"video_codec"`

View File

@ -76,6 +76,60 @@ func (qb *SceneQueryBuilder) Update(updatedScene ScenePartial, tx *sqlx.Tx) (*Sc
return qb.find(updatedScene.ID, tx)
}
func (qb *SceneQueryBuilder) IncrementOCounter(id int, tx *sqlx.Tx) (int, error) {
ensureTx(tx)
_, err := tx.Exec(
`UPDATE scenes SET o_counter = o_counter + 1 WHERE scenes.id = ?`,
id,
)
if err != nil {
return 0, err
}
scene, err := qb.find(id, tx)
if err != nil {
return 0, err
}
return scene.OCounter, nil
}
func (qb *SceneQueryBuilder) DecrementOCounter(id int, tx *sqlx.Tx) (int, error) {
ensureTx(tx)
_, err := tx.Exec(
`UPDATE scenes SET o_counter = o_counter - 1 WHERE scenes.id = ? and scenes.o_counter > 0`,
id,
)
if err != nil {
return 0, err
}
scene, err := qb.find(id, tx)
if err != nil {
return 0, err
}
return scene.OCounter, nil
}
func (qb *SceneQueryBuilder) ResetOCounter(id int, tx *sqlx.Tx) (int, error) {
ensureTx(tx)
_, err := tx.Exec(
`UPDATE scenes SET o_counter = 0 WHERE scenes.id = ?`,
id,
)
if err != nil {
return 0, err
}
scene, err := qb.find(id, tx)
if err != nil {
return 0, err
}
return scene.OCounter, nil
}
func (qb *SceneQueryBuilder) Destroy(id string, tx *sqlx.Tx) error {
return executeDeleteQuery("scenes", id, tx)
}
@ -178,6 +232,14 @@ func (qb *SceneQueryBuilder) Query(sceneFilter *SceneFilterType, findFilter *Fin
}
}
if oCounter := sceneFilter.OCounter; oCounter != nil {
clause, count := getIntCriterionWhereClause("scenes.o_counter", *sceneFilter.OCounter)
whereClauses = append(whereClauses, clause)
if count == 1 {
args = append(args, sceneFilter.OCounter.Value)
}
}
if durationFilter := sceneFilter.Duration; durationFilter != nil {
clause, thisArgs := getDurationWhereClause(*durationFilter)
whereClauses = append(whereClauses, clause)

View File

@ -95,7 +95,7 @@ func getSort(sort string, direction string, tableName string) string {
const randomSeedPrefix = "random_"
if strings.Contains(sort, "_count") {
if strings.HasSuffix(sort, "_count") {
var relationTableName = strings.Split(sort, "_")[0] // TODO: pluralize?
colName := getColumn(relationTableName, "id")
return " ORDER BY COUNT(distinct " + colName + ") " + direction

View File

@ -0,0 +1,50 @@
import React, { FunctionComponent } from "react";
import { Button, Popover, Menu, MenuItem } from "@blueprintjs/core";
import { Icons } from "../../utils/icons";
export interface IOCounterButtonProps {
loading: boolean
value: number
onIncrement: () => void
onDecrement: () => void
onReset: () => void
onMenuOpened?: () => void
onMenuClosed?: () => void
}
export const OCounterButton: FunctionComponent<IOCounterButtonProps> = (props: IOCounterButtonProps) => {
function renderButton() {
return (
<Button
loading={props.loading}
icon={Icons.sweatDrops()}
text={props.value}
minimal={true}
onClick={props.onIncrement}
disabled={props.loading}
/>
);
}
if (props.value) {
// just render the button by itself
return (
<Popover
interactionKind={"hover"}
hoverOpenDelay={1000}
position="bottom"
disabled={props.loading}
onOpening={props.onMenuOpened}
onClosing={props.onMenuClosed}
>
{renderButton()}
<Menu>
<MenuItem text="Decrement" icon="minus" onClick={props.onDecrement}/>
<MenuItem text="Reset" icon="disable" onClick={props.onReset}/>
</Menu>
</Popover>
);
} else {
return renderButton();
}
}

View File

@ -17,6 +17,7 @@ import { TextUtils } from "../../utils/text";
import { TagLink } from "../Shared/TagLink";
import { ZoomUtils } from "../../utils/zoom";
import { StashService } from "../../core/StashService";
import { Icons } from "../../utils/icons";
interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment;
@ -142,10 +143,22 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
);
}
function maybeRenderOCounter() {
if (props.scene.o_counter) {
return (
<Button
icon={Icons.sweatDrops()}
text={props.scene.o_counter}
/>
)
}
}
function maybeRenderPopoverButtonGroup() {
if (props.scene.tags.length > 0 ||
props.scene.performers.length > 0 ||
props.scene.scene_markers.length > 0) {
props.scene.scene_markers.length > 0 ||
props.scene.o_counter) {
return (
<>
<Divider />
@ -153,6 +166,7 @@ export const SceneCard: FunctionComponent<ISceneCardProps> = (props: ISceneCardP
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderSceneMarkerPopoverButton()}
{maybeRenderOCounter()}
</ButtonGroup>
</>
);

View File

@ -16,6 +16,8 @@ import { SceneEditPanel } from "./SceneEditPanel";
import { SceneFileInfoPanel } from "./SceneFileInfoPanel";
import { SceneMarkersPanel } from "./SceneMarkersPanel";
import { ScenePerformerPanel } from "./ScenePerformerPanel";
import { ErrorUtils } from "../../../utils/errors";
import { IOCounterButtonProps, OCounterButton } from "../OCounterButton";
interface ISceneProps extends IBaseProps {}
@ -24,7 +26,13 @@ export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => {
const [autoplay, setAutoplay] = useState<boolean>(false);
const [scene, setScene] = useState<Partial<GQL.SceneDataFragment>>({});
const [isLoading, setIsLoading] = useState(false);
const { data, error, loading } = StashService.useFindScene(props.match.params.id);
const { data, error, loading, refetch } = StashService.useFindScene(props.match.params.id);
const [oLoading, setOLoading] = useState(false);
const incrementO = StashService.useSceneIncrementO(scene.id || "0");
const decrementO = StashService.useSceneDecrementO(scene.id || "0");
const resetO = StashService.useSceneResetO(scene.id || "0");
useEffect(() => {
setIsLoading(loading);
@ -54,6 +62,56 @@ export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => {
Object.assign({scene_marker_tags: data.sceneMarkerTags}, scene) as GQL.SceneDataFragment; // TODO Hack from angular
if (!!error) { return <>error...</>; }
function updateOCounter(newValue: number) {
const modifiedScene = Object.assign({}, scene);
modifiedScene.o_counter = newValue;
setScene(modifiedScene);
}
async function onIncrementClick() {
try {
setOLoading(true);
const result = await incrementO();
updateOCounter(result.data.sceneIncrementO);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setOLoading(false);
}
}
async function onDecrementClick() {
try {
setOLoading(true);
const result = await decrementO();
updateOCounter(result.data.sceneDecrementO);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setOLoading(false);
}
}
async function onResetClick() {
try {
setOLoading(true);
const result = await resetO();
updateOCounter(result.data.sceneResetO);
} catch (e) {
ErrorUtils.handle(e);
} finally {
setOLoading(false);
}
}
const oCounterProps : IOCounterButtonProps = {
loading: oLoading,
value: scene.o_counter || 0,
onIncrement: onIncrementClick,
onDecrement: onDecrementClick,
onReset: onResetClick
}
return (
<>
<ScenePlayer scene={modifiedScene} timestamp={timestamp} autoplay={autoplay}/>
@ -93,6 +151,11 @@ export const Scene: FunctionComponent<ISceneProps> = (props: ISceneProps) => {
onDelete={() => props.history.push("/scenes")}
/>}
/>
<Tabs.Expander />
<OCounterButton
{...oCounterProps}
/>
</Tabs>
</Card>
</>

View File

@ -43,6 +43,7 @@ export const SceneDetailPanel: FunctionComponent<ISceneDetailProps> = (props: IS
<H1 className="bp3-heading">
{!!props.scene.title ? props.scene.title : TextUtils.fileNameFromPath(props.scene.path)}
</H1>
{!!props.scene.date ? <H4>{props.scene.date}</H4> : undefined}
{!!props.scene.rating ? <H6>Rating: {props.scene.rating}</H6> : undefined}
{!!props.scene.file.height ? <H6>Resolution: {TextUtils.resolution(props.scene.file.height)}</H6> : undefined}

View File

@ -315,6 +315,24 @@ export class StashService {
return GQL.useScenesUpdate({ variables: { input: input } });
}
public static useSceneIncrementO(id: string) {
return GQL.useSceneIncrementO({
variables: {id: id}
});
}
public static useSceneDecrementO(id: string) {
return GQL.useSceneDecrementO({
variables: {id: id}
});
}
public static useSceneResetO(id: string) {
return GQL.useSceneResetO({
variables: {id: id}
});
}
public static useSceneDestroy(input: GQL.SceneDestroyInput) {
return GQL.useSceneDestroy({
variables: input,

View File

@ -6,6 +6,7 @@ import { DurationUtils } from "../../../utils/duration";
export type CriterionType =
"none" |
"rating" |
"o_counter" |
"resolution" |
"duration" |
"favorite" |
@ -33,6 +34,7 @@ export abstract class Criterion<Option = any, Value = any> {
switch (type) {
case "none": return "None";
case "rating": return "Rating";
case "o_counter": return "O-Counter";
case "resolution": return "Resolution";
case "duration": return "Duration";
case "favorite": return "Favorite";

View File

@ -16,6 +16,7 @@ export function makeCriteria(type: CriterionType = "none") {
switch (type) {
case "none": return new NoneCriterion();
case "rating": return new RatingCriterion();
case "o_counter": return new NumberCriterion(type, type);
case "resolution": return new ResolutionCriterion();
case "duration": return new DurationCriterion(type, type);
case "favorite": return new FavoriteCriterion();

View File

@ -56,7 +56,7 @@ export class ListFilterModel {
switch (filterMode) {
case FilterMode.Scenes:
if (!!this.sortBy === false) { this.sortBy = "date"; }
this.sortByOptions = ["title", "path", "rating", "date", "filesize", "duration", "framerate", "bitrate", "random"];
this.sortByOptions = ["title", "path", "rating", "o_counter", "date", "filesize", "duration", "framerate", "bitrate", "random"];
this.displayModeOptions = [
DisplayMode.Grid,
DisplayMode.List,
@ -65,6 +65,7 @@ export class ListFilterModel {
this.criterionOptions = [
new NoneCriterionOption(),
new RatingCriterionOption(),
ListFilterModel.createCriterionOption("o_counter"),
new ResolutionCriterionOption(),
ListFilterModel.createCriterionOption("duration"),
new HasMarkersCriterionOption(),
@ -257,7 +258,7 @@ export class ListFilterModel {
q: this.searchTerm,
page: this.currentPage,
per_page: this.itemsPerPage,
sort: this.getSortBy(),
sort: this.sortBy,
direction: this.sortDirection === "asc" ? SortDirectionEnum.Asc : SortDirectionEnum.Desc,
};
}
@ -270,6 +271,10 @@ export class ListFilterModel {
const ratingCrit = criterion as RatingCriterion;
result.rating = { value: ratingCrit.value, modifier: ratingCrit.modifier };
break;
case "o_counter":
const oCounterCrit = criterion as NumberCriterion;
result.o_counter = { value: oCounterCrit.value, modifier: oCounterCrit.modifier };
break;
case "resolution": {
switch ((criterion as ResolutionCriterion).value) {
case "240p": result.resolution = ResolutionEnum.Low; break;

14
ui/v2/src/utils/icons.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from "react";
export class Icons {
public static sweatDrops() {
return (
<span className="bp3-icon">
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" aria-hidden="true" focusable="false" width="1em" height="1em" style={{transform: "rotate(360deg)"}} preserveAspectRatio="xMidYMid meet" viewBox="0 0 36 36">
<path fill="currentColor" d="M22.855.758L7.875 7.024l12.537 9.733c2.633 2.224 6.377 2.937 9.77 1.518c4.826-2.018 7.096-7.576 5.072-12.413C33.232 1.024 27.68-1.261 22.855.758zm-9.962 17.924L2.05 10.284L.137 23.529a7.993 7.993 0 0 0 2.958 7.803a8.001 8.001 0 0 0 9.798-12.65zm15.339 7.015l-8.156-4.69l-.033 9.223c-.088 2 .904 3.98 2.75 5.041a5.462 5.462 0 0 0 7.479-2.051c1.499-2.644.589-6.013-2.04-7.523z"/>
<rect x="0" y="0" width="36" height="36" fill="rgba(0, 0, 0, 0)" />
</svg>
</span>
);
}
}