diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index 3cf379d41..2cdf55635 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -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) diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index 795d02d51..4309cbbd7 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -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) diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 0b480c30e..2f487488f 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -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"` +} diff --git a/pkg/models/querybuilder_gallery.go b/pkg/models/querybuilder_gallery.go index 95c1f54a5..f77fe0de2 100644 --- a/pkg/models/querybuilder_gallery.go +++ b/pkg/models/querybuilder_gallery.go @@ -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} diff --git a/pkg/models/querybuilder_scene.go b/pkg/models/querybuilder_scene.go index a6f32fc2b..a6d3fc6f9 100644 --- a/pkg/models/querybuilder_scene.go +++ b/pkg/models/querybuilder_scene.go @@ -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) { diff --git a/pkg/models/querybuilder_sql.go b/pkg/models/querybuilder_sql.go index 35de14d29..7fe5c9363 100644 --- a/pkg/models/querybuilder_sql.go +++ b/pkg/models/querybuilder_sql.go @@ -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)) } } diff --git a/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx index d8f40542a..27cc6a8f6 100644 --- a/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx @@ -46,7 +46,7 @@ export const SceneEditPanel: FunctionComponent = (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 = (props: IProps) => { setGalleryId(item.id)} + onSelectItem={(item) => setGalleryId(item ? item.id : undefined)} /> setStudioId(item.id)} + onSelectItem={(item) => setStudioId(item ? item.id : undefined)} initialId={studioId} /> diff --git a/ui/v2/src/components/scenes/SceneDetails/SceneMarkersPanel.tsx b/ui/v2/src/components/scenes/SceneDetails/SceneMarkersPanel.tsx index bf74b6281..eb0527573 100644 --- a/ui/v2/src/components/scenes/SceneDetails/SceneMarkersPanel.tsx +++ b/ui/v2/src/components/scenes/SceneDetails/SceneMarkersPanel.tsx @@ -163,7 +163,7 @@ export const SceneMarkersPanel: FunctionComponent = (pr return ( fieldProps.form.setFieldValue("primaryTagId", tag.id)} + onSelectItem={(tag) => fieldProps.form.setFieldValue("primaryTagId", tag ? tag.id : undefined)} initialId={!!editingMarker ? editingMarker.primary_tag.id : undefined} /> ); diff --git a/ui/v2/src/components/select/FilterSelect.tsx b/ui/v2/src/components/select/FilterSelect.tsx index 8e5ebd992..6736fa880 100644 --- a/ui/v2/src/components/select/FilterSelect.tsx +++ b/ui/v2/src/components/select/FilterSelect.tsx @@ -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 = (props: IProps) => { @@ -28,12 +33,14 @@ export const FilterSelect: React.FunctionComponent = (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 = (props: IProps) => } /* eslint-disable react-hooks/rules-of-hooks */ - const [selectedItem, setSelectedItem] = React.useState(null); - const [isInitialized, setIsInitialized] = React.useState(false); - /* eslint-enable */ + const [selectedItem, setSelectedItem] = React.useState(undefined); - if (!!props.initialId && !selectedItem && !isInitialized) { - const initialItem = items.find((item) => props.initialId === item.id); - if (!!initialItem) { - setSelectedItem(initialItem); - setIsInitialized(true); + React.useEffect(() => { + if (!!items) { + const initialItem = items.find((item) => props.initialId === item.id); + if (!!initialItem) { + setSelectedItem(initialItem); + } else { + setSelectedItem(undefined); + } } - } + }, [props.initialId, items]); + /* eslint-enable */ const renderItem: ItemRenderer = (item, itemProps) => { if (!itemProps.modifiers.matchesPredicate) { return null; } @@ -80,7 +89,11 @@ export const FilterSelect: React.FunctionComponent = (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); } diff --git a/ui/v2/src/components/select/ValidGalleriesSelect.tsx b/ui/v2/src/components/select/ValidGalleriesSelect.tsx index 312cedcec..1c3c140df 100644 --- a/ui/v2/src/components/select/ValidGalleriesSelect.tsx +++ b/ui/v2/src/components/select/ValidGalleriesSelect.tsx @@ -11,7 +11,7 @@ const InternalSelect = Select.ofType void; + onSelectItem: (item: GQL.ValidGalleriesForSceneValidGalleriesForScene | undefined) => void; } export const ValidGalleriesSelect: React.FunctionComponent = (props: IProps) => { @@ -20,7 +20,7 @@ export const ValidGalleriesSelect: React.FunctionComponent = (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(null); + const [selectedItem, setSelectedItem] = React.useState(undefined); const [isInitialized, setIsInitialized] = React.useState(false); if (!!props.initialId && !selectedItem && !isInitialized) { @@ -49,7 +49,11 @@ export const ValidGalleriesSelect: React.FunctionComponent = (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); } diff --git a/ui/v2/src/utils/table.tsx b/ui/v2/src/utils/table.tsx index 581a5c1a9..8aaf2df63 100644 --- a/ui/v2/src/utils/table.tsx +++ b/ui/v2/src/utils/table.tsx @@ -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 ( @@ -132,7 +132,7 @@ export class TableUtils { options.onChange(item.id)} + onSelectItem={(item) => options.onChange(item ? item.id : undefined)} initialId={options.initialId} />