mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
Merge pull request #137 from WithoutPants/nullable_scene_props
Allow unsetting of rating, studio, gallery
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user