mirror of https://github.com/stashapp/stash.git
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:
parent
066295f0c9
commit
f87117b0d6
|
@ -6,6 +6,7 @@ fragment SlimSceneData on Scene {
|
|||
url
|
||||
date
|
||||
rating
|
||||
o_counter
|
||||
path
|
||||
|
||||
file {
|
||||
|
|
|
@ -6,6 +6,7 @@ fragment SceneData on Scene {
|
|||
url
|
||||
date
|
||||
rating
|
||||
o_counter
|
||||
path
|
||||
|
||||
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})
|
||||
}
|
|
@ -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!
|
||||
|
|
|
@ -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)"""
|
||||
|
|
|
@ -26,6 +26,7 @@ type Scene {
|
|||
url: String
|
||||
date: String
|
||||
rating: Int
|
||||
o_counter: Int
|
||||
path: String!
|
||||
|
||||
file: SceneFileType! # Resolver
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ import (
|
|||
)
|
||||
|
||||
var DB *sqlx.DB
|
||||
var appSchemaVersion uint = 2
|
||||
var appSchemaVersion uint = 3
|
||||
|
||||
const sqlite3Driver = "sqlite3_regexp"
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `scenes` ADD COLUMN `o_counter` tinyint not null default 0;
|
|
@ -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"`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue