Merge pull request #137 from WithoutPants/nullable_scene_props

Allow unsetting of rating, studio, gallery
This commit is contained in:
Leopere
2019-10-15 10:09:26 -04:00
committed by GitHub
11 changed files with 147 additions and 56 deletions

View File

@@ -3,38 +3,47 @@ package api
import (
"context"
"database/sql"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/models"
"strconv"
"time"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/models"
)
func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (*models.Scene, error) {
// Populate scene from the input
sceneID, _ := strconv.Atoi(input.ID)
updatedTime := time.Now()
updatedScene := models.Scene{
updatedScene := models.ScenePartial{
ID: sceneID,
UpdatedAt: models.SQLiteTimestamp{Timestamp: updatedTime},
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
}
if input.Title != nil {
updatedScene.Title = sql.NullString{String: *input.Title, Valid: true}
updatedScene.Title = &sql.NullString{String: *input.Title, Valid: true}
}
if input.Details != nil {
updatedScene.Details = sql.NullString{String: *input.Details, Valid: true}
updatedScene.Details = &sql.NullString{String: *input.Details, Valid: true}
}
if input.URL != nil {
updatedScene.URL = sql.NullString{String: *input.URL, Valid: true}
updatedScene.URL = &sql.NullString{String: *input.URL, Valid: true}
}
if input.Date != nil {
updatedScene.Date = models.SQLiteDate{String: *input.Date, Valid: true}
updatedScene.Date = &models.SQLiteDate{String: *input.Date, Valid: true}
}
if input.Rating != nil {
updatedScene.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true}
updatedScene.Rating = &sql.NullInt64{Int64: int64(*input.Rating), Valid: true}
} else {
// rating must be nullable
updatedScene.Rating = &sql.NullInt64{Valid: false}
}
if input.StudioID != nil {
studioID, _ := strconv.ParseInt(*input.StudioID, 10, 64)
updatedScene.StudioID = sql.NullInt64{Int64: studioID, Valid: true}
updatedScene.StudioID = &sql.NullInt64{Int64: studioID, Valid: true}
} else {
// studio must be nullable
updatedScene.StudioID = &sql.NullInt64{Valid: false}
}
// Start the transaction and save the scene marker
@@ -47,6 +56,14 @@ func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUp
return nil, err
}
// Clear the existing gallery value
gqb := models.NewGalleryQueryBuilder()
err = gqb.ClearGalleryId(sceneID, tx)
if err != nil {
_ = tx.Rollback()
return nil, err
}
if input.GalleryID != nil {
// Save the gallery
galleryID, _ := strconv.Atoi(*input.GalleryID)

View File

@@ -113,8 +113,11 @@ func (t *ScanTask) scanScene() {
logger.Infof("%s already exists. Duplicate of %s ", t.FilePath, scene.Path)
} else {
logger.Infof("%s already exists. Updating path...", t.FilePath)
scene.Path = t.FilePath
_, err = qb.Update(*scene, tx)
scenePartial := models.ScenePartial{
ID: scene.ID,
Path: &t.FilePath,
}
_, err = qb.Update(scenePartial, tx)
}
} else {
logger.Infof("%s doesn't exist. Creating new item...", t.FilePath)

View File

@@ -25,3 +25,25 @@ type Scene struct {
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
type ScenePartial struct {
ID int `db:"id" json:"id"`
Checksum *string `db:"checksum" json:"checksum"`
Path *string `db:"path" json:"path"`
Title *sql.NullString `db:"title" json:"title"`
Details *sql.NullString `db:"details" json:"details"`
URL *sql.NullString `db:"url" json:"url"`
Date *SQLiteDate `db:"date" json:"date"`
Rating *sql.NullInt64 `db:"rating" json:"rating"`
Size *sql.NullString `db:"size" json:"size"`
Duration *sql.NullFloat64 `db:"duration" json:"duration"`
VideoCodec *sql.NullString `db:"video_codec" json:"video_codec"`
AudioCodec *sql.NullString `db:"audio_codec" json:"audio_codec"`
Width *sql.NullInt64 `db:"width" json:"width"`
Height *sql.NullInt64 `db:"height" json:"height"`
Framerate *sql.NullFloat64 `db:"framerate" json:"framerate"`
Bitrate *sql.NullInt64 `db:"bitrate" json:"bitrate"`
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}

View File

@@ -2,9 +2,10 @@ package models
import (
"database/sql"
"path/filepath"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database"
"path/filepath"
)
type GalleryQueryBuilder struct{}
@@ -50,6 +51,24 @@ func (qb *GalleryQueryBuilder) Update(updatedGallery Gallery, tx *sqlx.Tx) (*Gal
return &updatedGallery, nil
}
type GalleryNullSceneID struct {
SceneID sql.NullInt64
}
func (qb *GalleryQueryBuilder) ClearGalleryId(sceneID int, tx *sqlx.Tx) error {
ensureTx(tx)
_, err := tx.NamedExec(
`UPDATE galleries SET scene_id = null WHERE scene_id = :sceneid`,
GalleryNullSceneID{
SceneID: sql.NullInt64{
Int64: int64(sceneID),
Valid: true,
},
},
)
return err
}
func (qb *GalleryQueryBuilder) Find(id int) (*Gallery, error) {
query := "SELECT * FROM galleries WHERE id = ? LIMIT 1"
args := []interface{}{id}

View File

@@ -2,10 +2,11 @@ package models
import (
"database/sql"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database"
)
const scenesForPerformerQuery = `
@@ -60,26 +61,27 @@ func (qb *SceneQueryBuilder) Create(newScene Scene, tx *sqlx.Tx) (*Scene, error)
return &newScene, nil
}
func (qb *SceneQueryBuilder) Update(updatedScene Scene, tx *sqlx.Tx) (*Scene, error) {
func (qb *SceneQueryBuilder) Update(updatedScene ScenePartial, tx *sqlx.Tx) (*Scene, error) {
ensureTx(tx)
_, err := tx.NamedExec(
`UPDATE scenes SET `+SQLGenKeys(updatedScene)+` WHERE scenes.id = :id`,
`UPDATE scenes SET `+SQLGenKeysPartial(updatedScene)+` WHERE scenes.id = :id`,
updatedScene,
)
if err != nil {
return nil, err
}
if err := tx.Get(&updatedScene, `SELECT * FROM scenes WHERE id = ? LIMIT 1`, updatedScene.ID); err != nil {
return nil, err
}
return &updatedScene, nil
return qb.find(updatedScene.ID, tx)
}
func (qb *SceneQueryBuilder) Find(id int) (*Scene, error) {
return qb.find(id, nil)
}
func (qb *SceneQueryBuilder) find(id int, tx *sqlx.Tx) (*Scene, error) {
query := "SELECT * FROM scenes WHERE id = ? LIMIT 1"
args := []interface{}{id}
return qb.queryScene(query, args, nil)
return qb.queryScene(query, args, tx)
}
func (qb *SceneQueryBuilder) FindByChecksum(checksum string) (*Scene, error) {

View File

@@ -3,13 +3,14 @@ package models
import (
"database/sql"
"fmt"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/logger"
"math/rand"
"reflect"
"strconv"
"strings"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/logger"
)
var randomSortFloat = rand.Float64()
@@ -235,6 +236,17 @@ func ensureTx(tx *sqlx.Tx) {
// of keys for non empty key:values. These keys are formated
// keyname=:keyname with a comma seperating them
func SQLGenKeys(i interface{}) string {
return sqlGenKeys(i, false)
}
// support a partial interface. When a partial interface is provided,
// keys will always be included if the value is not null. The partial
// interface must therefore consist of pointers
func SQLGenKeysPartial(i interface{}) string {
return sqlGenKeys(i, true)
}
func sqlGenKeys(i interface{}, partial bool) string {
var query []string
v := reflect.ValueOf(i)
for i := 0; i < v.NumField(); i++ {
@@ -246,46 +258,45 @@ func SQLGenKeys(i interface{}) string {
}
switch t := v.Field(i).Interface().(type) {
case string:
if t != "" {
if partial || t != "" {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case int:
if t != 0 {
if partial || t != 0 {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case float64:
if t != 0 {
if partial || t != 0 {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case SQLiteTimestamp:
if !t.Timestamp.IsZero() {
if partial || !t.Timestamp.IsZero() {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case SQLiteDate:
if t.Valid {
if partial || t.Valid {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case sql.NullString:
if t.Valid {
if partial || t.Valid {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case sql.NullBool:
if t.Valid {
if partial || t.Valid {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case sql.NullInt64:
if t.Valid {
if partial || t.Valid {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case sql.NullFloat64:
if t.Valid {
if partial || t.Valid {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
default:
reflectValue := reflect.ValueOf(t)
kind := reflectValue.Kind()
isNil := reflectValue.IsNil()
if kind != reflect.Ptr && !isNil {
if !isNil {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
}

View File

@@ -46,7 +46,7 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
setDetails(state.details);
setUrl(state.url);
setDate(state.date);
setRating(state.rating);
setRating(state.rating == null ? NaN : state.rating);
setGalleryId(state.gallery ? state.gallery.id : undefined);
setStudioId(state.studio ? state.studio.id : undefined);
setPerformerIds(perfIds);
@@ -150,14 +150,14 @@ export const SceneEditPanel: FunctionComponent<IProps> = (props: IProps) => {
<ValidGalleriesSelect
sceneId={props.scene.id}
initialId={galleryId}
onSelectItem={(item) => setGalleryId(item.id)}
onSelectItem={(item) => setGalleryId(item ? item.id : undefined)}
/>
</FormGroup>
<FormGroup label="Studio">
<FilterSelect
type="studios"
onSelectItem={(item) => setStudioId(item.id)}
onSelectItem={(item) => setStudioId(item ? item.id : undefined)}
initialId={studioId}
/>
</FormGroup>

View File

@@ -163,7 +163,7 @@ export const SceneMarkersPanel: FunctionComponent<ISceneMarkersPanelProps> = (pr
return (
<FilterSelect
type="tags"
onSelectItem={(tag) => fieldProps.form.setFieldValue("primaryTagId", tag.id)}
onSelectItem={(tag) => fieldProps.form.setFieldValue("primaryTagId", tag ? tag.id : undefined)}
initialId={!!editingMarker ? editingMarker.primary_tag.id : undefined}
/>
);

View File

@@ -18,7 +18,12 @@ type ValidTypes =
interface IProps extends HTMLInputProps {
type: "performers" | "studios" | "tags";
initialId?: string;
onSelectItem: (item: ValidTypes) => void;
onSelectItem: (item: ValidTypes | undefined) => void;
}
function addNoneOption(items: ValidTypes[]) {
// Add a none option to clear the gallery
if (!items.find((item) => item.id === "0")) { items.unshift({id: "0", name: "None"}); }
}
export const FilterSelect: React.FunctionComponent<IProps> = (props: IProps) => {
@@ -28,12 +33,14 @@ export const FilterSelect: React.FunctionComponent<IProps> = (props: IProps) =>
case "performers": {
const { data } = StashService.useAllPerformersForFilter();
items = !!data && !!data.allPerformers ? data.allPerformers : [];
addNoneOption(items);
InternalSelect = InternalPerformerSelect;
break;
}
case "studios": {
const { data } = StashService.useAllStudiosForFilter();
items = !!data && !!data.allStudios ? data.allStudios : [];
addNoneOption(items);
InternalSelect = InternalStudioSelect;
break;
}
@@ -50,17 +57,19 @@ export const FilterSelect: React.FunctionComponent<IProps> = (props: IProps) =>
}
/* eslint-disable react-hooks/rules-of-hooks */
const [selectedItem, setSelectedItem] = React.useState<ValidTypes | null>(null);
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
/* eslint-enable */
const [selectedItem, setSelectedItem] = React.useState<ValidTypes | undefined>(undefined);
if (!!props.initialId && !selectedItem && !isInitialized) {
React.useEffect(() => {
if (!!items) {
const initialItem = items.find((item) => props.initialId === item.id);
if (!!initialItem) {
setSelectedItem(initialItem);
setIsInitialized(true);
} else {
setSelectedItem(undefined);
}
}
}, [props.initialId, items]);
/* eslint-enable */
const renderItem: ItemRenderer<ValidTypes> = (item, itemProps) => {
if (!itemProps.modifiers.matchesPredicate) { return null; }
@@ -80,7 +89,11 @@ export const FilterSelect: React.FunctionComponent<IProps> = (props: IProps) =>
return item.name!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
function onItemSelect(item: ValidTypes) {
function onItemSelect(item: ValidTypes | undefined) {
if (item && item.id == "0") {
item = undefined;
}
props.onSelectItem(item);
setSelectedItem(item);
}

View File

@@ -11,7 +11,7 @@ const InternalSelect = Select.ofType<GQL.ValidGalleriesForSceneValidGalleriesFor
interface IProps extends HTMLInputProps {
initialId?: string;
sceneId: string;
onSelectItem: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene) => void;
onSelectItem: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) => void;
}
export const ValidGalleriesSelect: React.FunctionComponent<IProps> = (props: IProps) => {
@@ -20,7 +20,7 @@ export const ValidGalleriesSelect: React.FunctionComponent<IProps> = (props: IPr
// Add a none option to clear the gallery
if (!items.find((item) => item.id === "0")) { items.unshift({id: "0", path: "None"}); }
const [selectedItem, setSelectedItem] = React.useState<GQL.ValidGalleriesForSceneValidGalleriesForScene | null>(null);
const [selectedItem, setSelectedItem] = React.useState<GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined>(undefined);
const [isInitialized, setIsInitialized] = React.useState<boolean>(false);
if (!!props.initialId && !selectedItem && !isInitialized) {
@@ -49,7 +49,11 @@ export const ValidGalleriesSelect: React.FunctionComponent<IProps> = (props: IPr
return item.path!.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
function onItemSelect(item: GQL.ValidGalleriesForSceneValidGalleriesForScene) {
function onItemSelect(item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) {
if (item && item.id == "0") {
item = undefined;
}
props.onSelectItem(item);
setSelectedItem(item);
}

View File

@@ -124,7 +124,7 @@ export class TableUtils {
title: string,
type: "performers" | "studios" | "tags",
initialId: string | undefined,
onChange: ((id: string) => void),
onChange: ((id: string | undefined) => void),
}) {
return (
<tr>
@@ -132,7 +132,7 @@ export class TableUtils {
<td>
<FilterSelect
type={options.type}
onSelectItem={(item) => options.onChange(item.id)}
onSelectItem={(item) => options.onChange(item ? item.id : undefined)}
initialId={options.initialId}
/>
</td>