diff --git a/graphql/documents/mutations/scene.graphql b/graphql/documents/mutations/scene.graphql
index c9763e84a..2fd6d9daf 100644
--- a/graphql/documents/mutations/scene.graphql
+++ b/graphql/documents/mutations/scene.graphql
@@ -1,3 +1,11 @@
+mutation SceneCreate(
+ $input: SceneCreateInput!) {
+
+ sceneCreate(input: $input) {
+ ...SceneData
+ }
+}
+
mutation SceneUpdate(
$input: SceneUpdateInput!) {
@@ -43,3 +51,13 @@ mutation ScenesDestroy($ids: [ID!]!, $delete_file: Boolean, $delete_generated :
mutation SceneGenerateScreenshot($id: ID!, $at: Float) {
sceneGenerateScreenshot(id: $id, at: $at)
}
+
+mutation SceneAssignFile($input: AssignSceneFileInput!) {
+ sceneAssignFile(input: $input)
+}
+
+mutation SceneMerge($input: SceneMergeInput!) {
+ sceneMerge(input: $input) {
+ id
+ }
+}
\ No newline at end of file
diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql
index 15dd669e6..6f2f37d96 100644
--- a/graphql/schema/schema.graphql
+++ b/graphql/schema/schema.graphql
@@ -162,7 +162,9 @@ type Mutation {
setup(input: SetupInput!): Boolean!
migrate(input: MigrateInput!): Boolean!
+ sceneCreate(input: SceneCreateInput!): Scene
sceneUpdate(input: SceneUpdateInput!): Scene
+ sceneMerge(input: SceneMergeInput!): Scene
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
sceneDestroy(input: SceneDestroyInput!): Boolean!
scenesDestroy(input: ScenesDestroyInput!): Boolean!
@@ -182,6 +184,8 @@ type Mutation {
sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker
sceneMarkerDestroy(id: ID!): Boolean!
+ sceneAssignFile(input: AssignSceneFileInput!): Boolean!
+
imageUpdate(input: ImageUpdateInput!): Image
bulkImageUpdate(input: BulkImageUpdateInput!): [Image!]
imageDestroy(input: ImageDestroyInput!): Boolean!
diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql
index bad6edef9..6360c3204 100644
--- a/graphql/schema/types/scene.graphql
+++ b/graphql/schema/types/scene.graphql
@@ -74,6 +74,29 @@ input SceneMovieInput {
scene_index: Int
}
+input SceneCreateInput {
+ title: String
+ code: String
+ details: String
+ director: String
+ url: String
+ date: String
+ rating: Int
+ organized: Boolean
+ studio_id: ID
+ gallery_ids: [ID!]
+ performer_ids: [ID!]
+ movies: [SceneMovieInput!]
+ tag_ids: [ID!]
+ """This should be a URL or a base64 encoded data URL"""
+ cover_image: String
+ stash_ids: [StashIDInput!]
+
+ """The first id will be assigned as primary. Files will be reassigned from
+ existing scenes if applicable. Files must not already be primary for another scene"""
+ file_ids: [ID!]
+}
+
input SceneUpdateInput {
clientMutationId: String
id: ID!
@@ -84,6 +107,7 @@ input SceneUpdateInput {
url: String
date: String
rating: Int
+ o_counter: Int
organized: Boolean
studio_id: ID
gallery_ids: [ID!]
@@ -190,3 +214,17 @@ type SceneStreamEndpoint {
mime_type: String
label: String
}
+
+input AssignSceneFileInput {
+ scene_id: ID!
+ file_id: ID!
+}
+
+input SceneMergeInput {
+ """If destination scene has no files, then the primary file of the
+ first source scene will be assigned as primary"""
+ source: [ID!]!
+ destination: ID!
+ # values defined here will override values in the destination
+ values: SceneUpdateInput
+}
diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go
index 741885680..3cbb0ea48 100644
--- a/internal/api/changeset_translator.go
+++ b/internal/api/changeset_translator.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strconv"
+ "strings"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
@@ -19,19 +20,35 @@ func getArgumentMap(ctx context.Context) map[string]interface{} {
}
func getUpdateInputMap(ctx context.Context) map[string]interface{} {
+ return getNamedUpdateInputMap(ctx, updateInputField)
+}
+
+func getNamedUpdateInputMap(ctx context.Context, field string) map[string]interface{} {
args := getArgumentMap(ctx)
- input := args[updateInputField]
- var ret map[string]interface{}
- if input != nil {
- ret, _ = input.(map[string]interface{})
+ // field can be qualified
+ fields := strings.Split(field, ".")
+
+ currArgs := args
+
+ for _, f := range fields {
+ v, found := currArgs[f]
+ if !found {
+ currArgs = nil
+ break
+ }
+
+ currArgs, _ = v.(map[string]interface{})
+ if currArgs == nil {
+ break
+ }
}
- if ret == nil {
- ret = make(map[string]interface{})
+ if currArgs != nil {
+ return currArgs
}
- return ret
+ return make(map[string]interface{})
}
func getUpdateInputMaps(ctx context.Context) []map[string]interface{} {
@@ -90,6 +107,14 @@ func (t changesetTranslator) nullString(value *string, field string) *sql.NullSt
return ret
}
+func (t changesetTranslator) string(value *string, field string) string {
+ if value == nil {
+ return ""
+ }
+
+ return *value
+}
+
func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString {
if !t.hasField(field) {
return models.OptionalString{}
@@ -128,6 +153,27 @@ func (t changesetTranslator) optionalDate(value *string, field string) models.Op
return models.NewOptionalDate(models.NewDate(*value))
}
+func (t changesetTranslator) datePtr(value *string, field string) *models.Date {
+ if value == nil {
+ return nil
+ }
+
+ d := models.NewDate(*value)
+ return &d
+}
+
+func (t changesetTranslator) intPtrFromString(value *string, field string) (*int, error) {
+ if value == nil || *value == "" {
+ return nil, nil
+ }
+
+ vv, err := strconv.Atoi(*value)
+ if err != nil {
+ return nil, fmt.Errorf("converting %v to int: %w", *value, err)
+ }
+ return &vv, nil
+}
+
func (t changesetTranslator) nullInt64(value *int, field string) *sql.NullInt64 {
if !t.hasField(field) {
return nil
@@ -185,6 +231,14 @@ func (t changesetTranslator) optionalIntFromString(value *string, field string)
return models.NewOptionalInt(vv), nil
}
+func (t changesetTranslator) bool(value *bool, field string) bool {
+ if value == nil {
+ return false
+ }
+
+ return *value
+}
+
func (t changesetTranslator) optionalBool(value *bool, field string) models.OptionalBool {
if !t.hasField(field) {
return models.OptionalBool{}
diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go
index 0f9dc2b19..62213e7f0 100644
--- a/internal/api/resolver_model_scene.go
+++ b/internal/api/resolver_model_scene.go
@@ -29,6 +29,8 @@ func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (
obj.Files.SetPrimary(ret)
return ret, nil
+ } else {
+ _ = obj.LoadPrimaryFile(ctx, r.repository.File)
}
return nil, nil
diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go
index ff1981ef5..15aff6c6e 100644
--- a/internal/api/resolver_mutation_scene.go
+++ b/internal/api/resolver_mutation_scene.go
@@ -3,6 +3,7 @@ package api
import (
"context"
"database/sql"
+ "errors"
"fmt"
"strconv"
"time"
@@ -30,6 +31,79 @@ func (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Sc
return ret, nil
}
+func (r *mutationResolver) SceneCreate(ctx context.Context, input SceneCreateInput) (ret *models.Scene, err error) {
+ translator := changesetTranslator{
+ inputMap: getUpdateInputMap(ctx),
+ }
+
+ performerIDs, err := stringslice.StringSliceToIntSlice(input.PerformerIds)
+ if err != nil {
+ return nil, fmt.Errorf("converting performer ids: %w", err)
+ }
+ tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds)
+ if err != nil {
+ return nil, fmt.Errorf("converting tag ids: %w", err)
+ }
+ galleryIDs, err := stringslice.StringSliceToIntSlice(input.GalleryIds)
+ if err != nil {
+ return nil, fmt.Errorf("converting gallery ids: %w", err)
+ }
+
+ moviesScenes, err := models.MoviesScenesFromInput(input.Movies)
+ if err != nil {
+ return nil, fmt.Errorf("converting movies scenes: %w", err)
+ }
+
+ fileIDsInt, err := stringslice.StringSliceToIntSlice(input.FileIds)
+ if err != nil {
+ return nil, fmt.Errorf("converting file ids: %w", err)
+ }
+
+ fileIDs := make([]file.ID, len(fileIDsInt))
+ for i, v := range fileIDsInt {
+ fileIDs[i] = file.ID(v)
+ }
+
+ newScene := models.Scene{
+ Title: translator.string(input.Title, "title"),
+ Code: translator.string(input.Code, "code"),
+ Details: translator.string(input.Details, "details"),
+ Director: translator.string(input.Director, "director"),
+ URL: translator.string(input.URL, "url"),
+ Date: translator.datePtr(input.Date, "date"),
+ Rating: input.Rating,
+ Organized: translator.bool(input.Organized, "organized"),
+ PerformerIDs: models.NewRelatedIDs(performerIDs),
+ TagIDs: models.NewRelatedIDs(tagIDs),
+ GalleryIDs: models.NewRelatedIDs(galleryIDs),
+ Movies: models.NewRelatedMovies(moviesScenes),
+ StashIDs: models.NewRelatedStashIDs(stashIDPtrSliceToSlice(input.StashIds)),
+ }
+
+ newScene.StudioID, err = translator.intPtrFromString(input.StudioID, "studio_id")
+ if err != nil {
+ return nil, fmt.Errorf("converting studio id: %w", err)
+ }
+
+ var coverImageData []byte
+ if input.CoverImage != nil && *input.CoverImage != "" {
+ var err error
+ coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if err := r.withTxn(ctx, func(ctx context.Context) error {
+ ret, err = r.Resolver.sceneService.Create(ctx, &newScene, fileIDs, coverImageData)
+ return err
+ }); err != nil {
+ return nil, err
+ }
+
+ return ret, nil
+}
+
func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (ret *models.Scene, err error) {
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
@@ -90,26 +164,7 @@ func (r *mutationResolver) ScenesUpdate(ctx context.Context, input []*models.Sce
return newRet, nil
}
-func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) {
- // Populate scene from the input
- sceneID, err := strconv.Atoi(input.ID)
- if err != nil {
- return nil, err
- }
-
- qb := r.repository.Scene
-
- s, err := qb.Find(ctx, sceneID)
- if err != nil {
- return nil, err
- }
-
- if s == nil {
- return nil, fmt.Errorf("scene with id %d not found", sceneID)
- }
-
- var coverImageData []byte
-
+func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTranslator) (*models.ScenePartial, error) {
updatedScene := models.NewScenePartial()
updatedScene.Title = translator.optionalString(input.Title, "title")
updatedScene.Code = translator.optionalString(input.Code, "code")
@@ -118,6 +173,8 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
updatedScene.URL = translator.optionalString(input.URL, "url")
updatedScene.Date = translator.optionalDate(input.Date, "date")
updatedScene.Rating = translator.optionalInt(input.Rating, "rating")
+ updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter")
+ var err error
updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)
@@ -133,36 +190,6 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
converted := file.ID(primaryFileID)
updatedScene.PrimaryFileID = &converted
-
- // if file hash has changed, we should migrate generated files
- // after commit
- if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
- return nil, err
- }
-
- // ensure that new primary file is associated with scene
- var f *file.VideoFile
- for _, ff := range s.Files.List() {
- if ff.ID == converted {
- f = ff
- }
- }
-
- if f == nil {
- return nil, fmt.Errorf("file with id %d not associated with scene", converted)
- }
-
- fileNamingAlgorithm := config.GetInstance().GetVideoFileNamingAlgorithm()
- oldHash := scene.GetHash(s.Files.Primary(), fileNamingAlgorithm)
- newHash := scene.GetHash(f, fileNamingAlgorithm)
-
- if oldHash != "" && newHash != "" && oldHash != newHash {
- // perform migration after commit
- txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
- scene.MigrateHash(manager.GetInstance().Paths, oldHash, newHash)
- return nil
- })
- }
}
if translator.hasField("performer_ids") {
@@ -202,39 +229,107 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
}
}
+ return &updatedScene, nil
+}
+
+func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) {
+ // Populate scene from the input
+ sceneID, err := strconv.Atoi(input.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ qb := r.repository.Scene
+
+ s, err := qb.Find(ctx, sceneID)
+ if err != nil {
+ return nil, err
+ }
+
+ if s == nil {
+ return nil, fmt.Errorf("scene with id %d not found", sceneID)
+ }
+
+ var coverImageData []byte
+
+ updatedScene, err := scenePartialFromInput(input, translator)
+ if err != nil {
+ return nil, err
+ }
+
+ // ensure that title is set where scene has no file
+ if updatedScene.Title.Set && updatedScene.Title.Value == "" {
+ if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
+ return nil, err
+ }
+
+ if len(s.Files.List()) == 0 {
+ return nil, errors.New("title must be set if scene has no files")
+ }
+ }
+
+ if updatedScene.PrimaryFileID != nil {
+ newPrimaryFileID := *updatedScene.PrimaryFileID
+
+ // if file hash has changed, we should migrate generated files
+ // after commit
+ if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
+ return nil, err
+ }
+
+ // ensure that new primary file is associated with scene
+ var f *file.VideoFile
+ for _, ff := range s.Files.List() {
+ if ff.ID == newPrimaryFileID {
+ f = ff
+ }
+ }
+
+ if f == nil {
+ return nil, fmt.Errorf("file with id %d not associated with scene", newPrimaryFileID)
+ }
+ }
+
if input.CoverImage != nil && *input.CoverImage != "" {
var err error
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
if err != nil {
return nil, err
}
-
- // update the cover after updating the scene
}
- s, err = qb.UpdatePartial(ctx, sceneID, updatedScene)
+ s, err = qb.UpdatePartial(ctx, sceneID, *updatedScene)
if err != nil {
return nil, err
}
- // update cover table
- if len(coverImageData) > 0 {
- if err := qb.UpdateCover(ctx, sceneID, coverImageData); err != nil {
- return nil, err
- }
- }
-
- // only update the cover image if provided and everything else was successful
- if coverImageData != nil {
- err = scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
- if err != nil {
- return nil, err
- }
+ if err := r.sceneUpdateCoverImage(ctx, s, coverImageData); err != nil {
+ return nil, err
}
return s, nil
}
+func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {
+ if len(coverImageData) > 0 {
+ qb := r.repository.Scene
+
+ // update cover table
+ if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
+ return err
+ }
+
+ if s.Path != "" {
+ // update the file-based screenshot after commit
+ txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
+ return scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
+ })
+ }
+ }
+
+ return nil
+}
+
func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneUpdateInput) ([]*models.Scene, error) {
sceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
if err != nil {
@@ -486,6 +581,84 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
return true, nil
}
+func (r *mutationResolver) SceneAssignFile(ctx context.Context, input AssignSceneFileInput) (bool, error) {
+ sceneID, err := strconv.Atoi(input.SceneID)
+ if err != nil {
+ return false, fmt.Errorf("converting scene ID: %w", err)
+ }
+
+ fileIDInt, err := strconv.Atoi(input.FileID)
+ if err != nil {
+ return false, fmt.Errorf("converting file ID: %w", err)
+ }
+
+ fileID := file.ID(fileIDInt)
+
+ if err := r.withTxn(ctx, func(ctx context.Context) error {
+ return r.Resolver.sceneService.AssignFile(ctx, sceneID, fileID)
+ }); err != nil {
+ return false, fmt.Errorf("assigning file to scene: %w", err)
+ }
+
+ return true, nil
+}
+
+func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput) (*models.Scene, error) {
+ srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
+ if err != nil {
+ return nil, fmt.Errorf("converting source IDs: %w", err)
+ }
+
+ destID, err := strconv.Atoi(input.Destination)
+ if err != nil {
+ return nil, fmt.Errorf("converting destination ID %s: %w", input.Destination, err)
+ }
+
+ var values *models.ScenePartial
+ if input.Values != nil {
+ translator := changesetTranslator{
+ inputMap: getNamedUpdateInputMap(ctx, "input.values"),
+ }
+
+ values, err = scenePartialFromInput(*input.Values, translator)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ v := models.NewScenePartial()
+ values = &v
+ }
+
+ var coverImageData []byte
+
+ if input.Values.CoverImage != nil && *input.Values.CoverImage != "" {
+ var err error
+ coverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ var ret *models.Scene
+ if err := r.withTxn(ctx, func(ctx context.Context) error {
+ if err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, *values); err != nil {
+ return err
+ }
+
+ ret, err = r.Resolver.repository.Scene.Find(ctx, destID)
+
+ if err == nil && ret != nil {
+ err = r.sceneUpdateCoverImage(ctx, ret, coverImageData)
+ }
+
+ return err
+ }); err != nil {
+ return nil, err
+ }
+
+ return ret, nil
+}
+
func (r *mutationResolver) getSceneMarker(ctx context.Context, id int) (ret *models.SceneMarker, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.SceneMarker.Find(ctx, id)
diff --git a/internal/manager/manager.go b/internal/manager/manager.go
index 206b80ed2..9f2492ea7 100644
--- a/internal/manager/manager.go
+++ b/internal/manager/manager.go
@@ -169,6 +169,9 @@ func initialize() error {
db := sqlite.NewDatabase()
+ // start with empty paths
+ emptyPaths := paths.Paths{}
+
instance = &Manager{
Config: cfg,
Logger: l,
@@ -178,14 +181,18 @@ func initialize() error {
Database: db,
Repository: sqliteRepository(db),
+ Paths: &emptyPaths,
scanSubs: &subscriptionManager{},
}
instance.SceneService = &scene.Service{
- File: db.File,
- Repository: db.Scene,
- MarkerDestroyer: instance.Repository.SceneMarker,
+ File: db.File,
+ Repository: db.Scene,
+ MarkerRepository: instance.Repository.SceneMarker,
+ PluginCache: instance.PluginCache,
+ Paths: instance.Paths,
+ Config: cfg,
}
instance.ImageService = &image.Service{
@@ -444,7 +451,7 @@ func (s *Manager) PostInit(ctx context.Context) error {
logger.Warnf("could not set initial configuration: %v", err)
}
- s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
+ *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
s.RefreshConfig()
s.SessionStore = session.NewStore(s.Config)
s.PluginCache.RegisterSessionStore(s.SessionStore)
@@ -518,7 +525,7 @@ func (s *Manager) initScraperCache() *scraper.Cache {
}
func (s *Manager) RefreshConfig() {
- s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
+ *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
config := s.Config
if config.Validate() == nil {
if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil {
diff --git a/internal/manager/repository.go b/internal/manager/repository.go
index 3d64ee73e..ac8ddbb8c 100644
--- a/internal/manager/repository.go
+++ b/internal/manager/repository.go
@@ -92,6 +92,9 @@ func sqliteRepository(d *sqlite.Database) Repository {
}
type SceneService interface {
+ Create(ctx context.Context, input *models.Scene, fileIDs []file.ID, coverImage []byte) (*models.Scene, error)
+ AssignFile(ctx context.Context, sceneID int, fileID file.ID) error
+ Merge(ctx context.Context, sourceIDs []int, destinationID int, values models.ScenePartial) error
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
}
diff --git a/internal/manager/running_streams.go b/internal/manager/running_streams.go
index 5e329e6fb..38286121e 100644
--- a/internal/manager/running_streams.go
+++ b/internal/manager/running_streams.go
@@ -91,13 +91,15 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit
func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) {
const defaultSceneImage = "scene/scene.svg"
- filepath := GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
+ if scene.Path != "" {
+ filepath := GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
- // fall back to the scene image blob if the file isn't present
- screenshotExists, _ := fsutil.FileExists(filepath)
- if screenshotExists {
- http.ServeFile(w, r, filepath)
- return
+ // fall back to the scene image blob if the file isn't present
+ screenshotExists, _ := fsutil.FileExists(filepath)
+ if screenshotExists {
+ http.ServeFile(w, r, filepath)
+ return
+ }
}
var cover []byte
diff --git a/internal/manager/scene.go b/internal/manager/scene.go
index 5539ad231..30d1948d8 100644
--- a/internal/manager/scene.go
+++ b/internal/manager/scene.go
@@ -79,7 +79,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea
pf := scene.Files.Primary()
if pf == nil {
- return nil, fmt.Errorf("nil file")
+ return nil, nil
}
var ret []*SceneStreamEndpoint
diff --git a/internal/manager/task_autotag.go b/internal/manager/task_autotag.go
index ee487b724..91ead20cb 100644
--- a/internal/manager/task_autotag.go
+++ b/internal/manager/task_autotag.go
@@ -697,6 +697,11 @@ func (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
r := t.txnManager
if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error {
+ if t.scene.Path == "" {
+ // nothing to do
+ return nil
+ }
+
if t.performers {
if err := autotag.ScenePerformers(ctx, t.scene, r.Scene, r.Performer, t.cache); err != nil {
return fmt.Errorf("error tagging scene performers for %s: %v", t.scene.DisplayName(), err)
diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go
index b968a5e76..6bb597a07 100644
--- a/internal/manager/task_export.go
+++ b/internal/manager/task_export.go
@@ -583,7 +583,7 @@ func exportScene(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models
basename := filepath.Base(s.Path)
hash := s.OSHash
- fn := newSceneJSON.Filename(basename, hash)
+ fn := newSceneJSON.Filename(s.ID, basename, hash)
if err := t.json.saveScene(fn, newSceneJSON); err != nil {
logger.Errorf("[scenes] <%s> failed to save json: %s", sceneHash, err.Error())
diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go
index e75f51960..de4d6c3b0 100644
--- a/internal/manager/task_generate.go
+++ b/internal/manager/task_generate.go
@@ -295,21 +295,23 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
generator: g,
}
- sceneHash := scene.GetHash(task.fileNamingAlgorithm)
- addTask := false
- if j.overwrite || !task.doesVideoPreviewExist(sceneHash) {
- totals.previews++
- addTask = true
- }
+ if task.required() {
+ sceneHash := scene.GetHash(task.fileNamingAlgorithm)
+ addTask := false
+ if j.overwrite || !task.doesVideoPreviewExist(sceneHash) {
+ totals.previews++
+ addTask = true
+ }
- if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist(sceneHash)) {
- totals.imagePreviews++
- addTask = true
- }
+ if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist(sceneHash)) {
+ totals.imagePreviews++
+ addTask = true
+ }
- if addTask {
- totals.tasks++
- queue <- task
+ if addTask {
+ totals.tasks++
+ queue <- task
+ }
}
}
diff --git a/internal/manager/task_generate_interactive_heatmap_speed.go b/internal/manager/task_generate_interactive_heatmap_speed.go
index 27c780764..6aa2da049 100644
--- a/internal/manager/task_generate_interactive_heatmap_speed.go
+++ b/internal/manager/task_generate_interactive_heatmap_speed.go
@@ -58,7 +58,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) shouldGenerate() bool {
return false
}
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
- return !t.doesHeatmapExist(sceneHash) || t.Overwrite
+ return !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil || t.Overwrite
}
func (t *GenerateInteractiveHeatmapSpeedTask) doesHeatmapExist(sceneChecksum string) bool {
diff --git a/internal/manager/task_generate_markers.go b/internal/manager/task_generate_markers.go
index aca8dcb2c..0ef01c9d6 100644
--- a/internal/manager/task_generate_markers.go
+++ b/internal/manager/task_generate_markers.go
@@ -5,7 +5,7 @@ import (
"fmt"
"path/filepath"
- "github.com/stashapp/stash/pkg/ffmpeg"
+ "github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@@ -45,6 +45,10 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) {
if err := t.TxnManager.WithTxn(ctx, func(ctx context.Context) error {
var err error
scene, err = t.TxnManager.Scene.Find(ctx, int(t.Marker.SceneID.Int64))
+ if err == nil && scene != nil {
+ err = scene.LoadPrimaryFile(ctx, t.TxnManager.File)
+ }
+
return err
}); err != nil {
logger.Errorf("error finding scene for marker: %s", err.Error())
@@ -56,10 +60,10 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) {
return
}
- ffprobe := instance.FFProbe
- videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
- if err != nil {
- logger.Errorf("error reading video file: %s", err.Error())
+ videoFile := scene.Files.Primary()
+
+ if videoFile == nil {
+ // nothing to do
return
}
@@ -78,14 +82,9 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) {
return
}
- if len(sceneMarkers) == 0 {
- return
- }
+ videoFile := t.Scene.Files.Primary()
- ffprobe := instance.FFProbe
- videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
- if err != nil {
- logger.Errorf("error reading video file: %s", err.Error())
+ if len(sceneMarkers) == 0 || videoFile == nil {
return
}
@@ -105,7 +104,7 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) {
}
}
-func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) {
+func (t *GenerateMarkersTask) generateMarker(videoFile *file.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) {
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
seconds := int(sceneMarker.Seconds)
@@ -139,7 +138,7 @@ func (t *GenerateMarkersTask) markersNeeded(ctx context.Context) int {
return 0
}
- if len(sceneMarkers) == 0 {
+ if len(sceneMarkers) == 0 || t.Scene.Files.Primary() == nil {
return 0
}
diff --git a/internal/manager/task_generate_preview.go b/internal/manager/task_generate_preview.go
index 2e39a6d7c..30ab77a9c 100644
--- a/internal/manager/task_generate_preview.go
+++ b/internal/manager/task_generate_preview.go
@@ -73,6 +73,10 @@ func (t GeneratePreviewTask) generateWebp(videoChecksum string) error {
}
func (t GeneratePreviewTask) required() bool {
+ if t.Scene.Path == "" {
+ return false
+ }
+
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
videoExists := t.doesVideoPreviewExist(sceneHash)
imageExists := !t.ImagePreview || t.doesImagePreviewExist(sceneHash)
diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go
index 3d7e528df..2b5795777 100644
--- a/internal/manager/task_generate_screenshot.go
+++ b/internal/manager/task_generate_screenshot.go
@@ -23,6 +23,9 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) {
scenePath := t.Scene.Path
videoFile := t.Scene.Files.Primary()
+ if videoFile == nil {
+ return
+ }
var at float64
if t.ScreenshotAt == nil {
diff --git a/pkg/file/file.go b/pkg/file/file.go
index c5de0b8a9..525c0f329 100644
--- a/pkg/file/file.go
+++ b/pkg/file/file.go
@@ -154,6 +154,7 @@ type Finder interface {
// Getter provides methods to find Files.
type Getter interface {
+ Finder
FindByPath(ctx context.Context, path string) (File, error)
FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error)
FindByZipFileID(ctx context.Context, zipFileID ID) ([]File, error)
@@ -190,6 +191,8 @@ type Store interface {
Creator
Updater
Destroyer
+
+ IsPrimary(ctx context.Context, fileID ID) (bool, error)
}
// Decorator wraps the Decorate method to add additional functionality while scanning files.
diff --git a/pkg/models/jsonschema/scene.go b/pkg/models/jsonschema/scene.go
index 88dbb96b7..a2ac5695b 100644
--- a/pkg/models/jsonschema/scene.go
+++ b/pkg/models/jsonschema/scene.go
@@ -3,6 +3,7 @@ package jsonschema
import (
"fmt"
"os"
+ "strconv"
jsoniter "github.com/json-iterator/go"
"github.com/stashapp/stash/pkg/fsutil"
@@ -60,7 +61,7 @@ type Scene struct {
StashIDs []models.StashID `json:"stash_ids,omitempty"`
}
-func (s Scene) Filename(basename string, hash string) string {
+func (s Scene) Filename(id int, basename string, hash string) string {
ret := fsutil.SanitiseBasename(s.Title)
if ret == "" {
ret = basename
@@ -68,6 +69,9 @@ func (s Scene) Filename(basename string, hash string) string {
if hash != "" {
ret += "." + hash
+ } else {
+ // scenes may have no file and therefore no hash
+ ret += "." + strconv.Itoa(id)
}
return ret + ".json"
diff --git a/pkg/models/model_joins.go b/pkg/models/model_joins.go
index bcd47c9a9..5fe8b7fa5 100644
--- a/pkg/models/model_joins.go
+++ b/pkg/models/model_joins.go
@@ -41,21 +41,43 @@ func (u *UpdateMovieIDs) SceneMovieInputs() []*SceneMovieInput {
return ret
}
+func (u *UpdateMovieIDs) AddUnique(v MoviesScenes) {
+ for _, vv := range u.Movies {
+ if vv.MovieID == v.MovieID {
+ return
+ }
+ }
+
+ u.Movies = append(u.Movies, v)
+}
+
func UpdateMovieIDsFromInput(i []*SceneMovieInput) (*UpdateMovieIDs, error) {
ret := &UpdateMovieIDs{
Mode: RelationshipUpdateModeSet,
}
- for _, v := range i {
+ var err error
+ ret.Movies, err = MoviesScenesFromInput(i)
+ if err != nil {
+ return nil, err
+ }
+
+ return ret, nil
+}
+
+func MoviesScenesFromInput(input []*SceneMovieInput) ([]MoviesScenes, error) {
+ ret := make([]MoviesScenes, len(input))
+
+ for i, v := range input {
mID, err := strconv.Atoi(v.MovieID)
if err != nil {
return nil, fmt.Errorf("invalid movie ID: %s", v.MovieID)
}
- ret.Movies = append(ret.Movies, MoviesScenes{
+ ret[i] = MoviesScenes{
MovieID: mID,
SceneIndex: v.SceneIndex,
- })
+ }
}
return ret, nil
diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go
index d70db775a..b4dc45128 100644
--- a/pkg/models/model_scene.go
+++ b/pkg/models/model_scene.go
@@ -177,6 +177,7 @@ type SceneUpdateInput struct {
URL *string `json:"url"`
Date *string `json:"date"`
Rating *int `json:"rating"`
+ OCounter *int `json:"o_counter"`
Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"`
diff --git a/pkg/models/paths/paths.go b/pkg/models/paths/paths.go
index fcd029e65..612d5e2b7 100644
--- a/pkg/models/paths/paths.go
+++ b/pkg/models/paths/paths.go
@@ -13,13 +13,13 @@ type Paths struct {
SceneMarkers *sceneMarkerPaths
}
-func NewPaths(generatedPath string) *Paths {
+func NewPaths(generatedPath string) Paths {
p := Paths{}
p.Generated = newGeneratedPaths(generatedPath)
p.Scene = newScenePaths(p)
p.SceneMarkers = newSceneMarkerPaths(p)
- return &p
+ return p
}
func GetStashHomeDirectory() string {
diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go
index 41bd0a69c..91453c629 100644
--- a/pkg/models/relationships.go
+++ b/pkg/models/relationships.go
@@ -138,6 +138,19 @@ func (r *RelatedMovies) Add(movies ...MoviesScenes) {
r.list = append(r.list, movies...)
}
+// ForID returns the MoviesScenes object for the given movie ID. Returns nil if not found.
+func (r *RelatedMovies) ForID(id int) *MoviesScenes {
+ r.mustLoaded()
+
+ for _, v := range r.list {
+ if v.MovieID == id {
+ return &v
+ }
+ }
+
+ return nil
+}
+
func (r *RelatedMovies) load(fn func() ([]MoviesScenes, error)) error {
if r.Loaded() {
return nil
diff --git a/pkg/models/stash_ids.go b/pkg/models/stash_ids.go
index 448491e18..7b27a0637 100644
--- a/pkg/models/stash_ids.go
+++ b/pkg/models/stash_ids.go
@@ -9,3 +9,14 @@ type UpdateStashIDs struct {
StashIDs []StashID `json:"stash_ids"`
Mode RelationshipUpdateMode `json:"mode"`
}
+
+// AddUnique adds the stash id to the list, only if the endpoint/stashid pair does not already exist in the list.
+func (u *UpdateStashIDs) AddUnique(v StashID) {
+ for _, vv := range u.StashIDs {
+ if vv.StashID == v.StashID && vv.Endpoint == v.Endpoint {
+ return
+ }
+ }
+
+ u.StashIDs = append(u.StashIDs, v)
+}
diff --git a/pkg/models/value.go b/pkg/models/value.go
index 0adff1f83..7b99a83d1 100644
--- a/pkg/models/value.go
+++ b/pkg/models/value.go
@@ -23,6 +23,13 @@ func (o *OptionalString) Ptr() *string {
return &v
}
+// Merge sets the OptionalString if it is not already set, the destination value is empty and the source value is not empty.
+func (o *OptionalString) Merge(destVal string, srcVal string) {
+ if destVal == "" && srcVal != "" && !o.Set {
+ *o = NewOptionalString(srcVal)
+ }
+}
+
// NewOptionalString returns a new OptionalString with the given value.
func NewOptionalString(v string) OptionalString {
return OptionalString{v, false, true}
@@ -58,6 +65,13 @@ func (o *OptionalInt) Ptr() *int {
return &v
}
+// MergePtr sets the OptionalInt if it is not already set, the destination value is nil and the source value is not nil.
+func (o *OptionalInt) MergePtr(destVal *int, srcVal *int) {
+ if destVal == nil && srcVal != nil && !o.Set {
+ *o = NewOptionalInt(*srcVal)
+ }
+}
+
// NewOptionalInt returns a new OptionalInt with the given value.
func NewOptionalInt(v int) OptionalInt {
return OptionalInt{v, false, true}
@@ -138,6 +152,13 @@ func (o *OptionalBool) Ptr() *bool {
return &v
}
+// Merge sets the OptionalBool to true if it is not already set, the destination value is false and the source value is true.
+func (o *OptionalBool) Merge(destVal bool, srcVal bool) {
+ if !destVal && srcVal && !o.Set {
+ *o = NewOptionalBool(true)
+ }
+}
+
// NewOptionalBool returns a new OptionalBool with the given value.
func NewOptionalBool(v bool) OptionalBool {
return OptionalBool{v, false, true}
@@ -200,6 +221,13 @@ func NewOptionalDate(v Date) OptionalDate {
return OptionalDate{v, false, true}
}
+// Merge sets the OptionalDate if it is not already set, the destination value is nil and the source value is nil.
+func (o *OptionalDate) MergePtr(destVal *Date, srcVal *Date) {
+ if destVal == nil && srcVal != nil && !o.Set {
+ *o = NewOptionalDate(*srcVal)
+ }
+}
+
// NewOptionalBoolPtr returns a new OptionalDate with the given value.
// If the value is nil, the returned OptionalDate will be set and null.
func NewOptionalDatePtr(v *Date) OptionalDate {
diff --git a/pkg/scene/create.go b/pkg/scene/create.go
new file mode 100644
index 000000000..83fd5e56c
--- /dev/null
+++ b/pkg/scene/create.go
@@ -0,0 +1,76 @@
+package scene
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/stashapp/stash/pkg/file"
+ "github.com/stashapp/stash/pkg/logger"
+ "github.com/stashapp/stash/pkg/models"
+ "github.com/stashapp/stash/pkg/plugin"
+ "github.com/stashapp/stash/pkg/txn"
+)
+
+func (s *Service) Create(ctx context.Context, input *models.Scene, fileIDs []file.ID, coverImage []byte) (*models.Scene, error) {
+ // title must be set if no files are provided
+ if input.Title == "" && len(fileIDs) == 0 {
+ return nil, errors.New("title must be set if scene has no files")
+ }
+
+ now := time.Now()
+ newScene := *input
+ newScene.CreatedAt = now
+ newScene.UpdatedAt = now
+
+ // don't pass the file ids since they may be already assigned
+ // assign them afterwards
+ if err := s.Repository.Create(ctx, &newScene, nil); err != nil {
+ return nil, fmt.Errorf("creating new scene: %w", err)
+ }
+
+ for _, f := range fileIDs {
+ if err := s.AssignFile(ctx, newScene.ID, f); err != nil {
+ return nil, fmt.Errorf("assigning file %d to new scene: %w", f, err)
+ }
+ }
+
+ if len(fileIDs) > 0 {
+ // assign the primary to the first
+ if _, err := s.Repository.UpdatePartial(ctx, newScene.ID, models.ScenePartial{
+ PrimaryFileID: &fileIDs[0],
+ }); err != nil {
+ return nil, fmt.Errorf("setting primary file on new scene: %w", err)
+ }
+ }
+
+ // re-find the scene so that it correctly returns file-related fields
+ ret, err := s.Repository.Find(ctx, newScene.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(coverImage) > 0 {
+ if err := s.Repository.UpdateCover(ctx, ret.ID, coverImage); err != nil {
+ return nil, fmt.Errorf("setting cover on new scene: %w", err)
+ }
+
+ // only update the cover image if provided and everything else was successful
+ // only do this if there is a file associated
+ if len(fileIDs) > 0 {
+ txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
+ if err := SetScreenshot(s.Paths, ret.GetHash(s.Config.GetVideoFileNamingAlgorithm()), coverImage); err != nil {
+ logger.Errorf("Error setting screenshot: %v", err)
+ }
+
+ return nil
+ })
+ }
+ }
+
+ s.PluginCache.RegisterPostHooks(ctx, ret.ID, plugin.SceneCreatePost, nil, nil)
+
+ // re-find the scene so that it correctly returns file-related fields
+ return ret, nil
+}
diff --git a/pkg/scene/delete.go b/pkg/scene/delete.go
index 47449f1e3..622b54377 100644
--- a/pkg/scene/delete.go
+++ b/pkg/scene/delete.go
@@ -128,7 +128,7 @@ type MarkerDestroyer interface {
// Destroy deletes a scene and its associated relationships from the
// database.
func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
- mqb := s.MarkerDestroyer
+ mqb := s.MarkerRepository
markers, err := mqb.FindBySceneID(ctx, scene.ID)
if err != nil {
return err
diff --git a/pkg/scene/merge.go b/pkg/scene/merge.go
new file mode 100644
index 000000000..d14f9621f
--- /dev/null
+++ b/pkg/scene/merge.go
@@ -0,0 +1,144 @@
+package scene
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/stashapp/stash/pkg/file"
+ "github.com/stashapp/stash/pkg/fsutil"
+ "github.com/stashapp/stash/pkg/logger"
+ "github.com/stashapp/stash/pkg/models"
+ "github.com/stashapp/stash/pkg/sliceutil/intslice"
+ "github.com/stashapp/stash/pkg/txn"
+)
+
+func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, scenePartial models.ScenePartial) error {
+ // ensure source ids are unique
+ sourceIDs = intslice.IntAppendUniques(nil, sourceIDs)
+
+ // ensure destination is not in source list
+ if intslice.IntInclude(sourceIDs, destinationID) {
+ return errors.New("destination scene cannot be in source list")
+ }
+
+ dest, err := s.Repository.Find(ctx, destinationID)
+ if err != nil {
+ return fmt.Errorf("finding destination scene ID %d: %w", destinationID, err)
+ }
+
+ sources, err := s.Repository.FindMany(ctx, sourceIDs)
+ if err != nil {
+ return fmt.Errorf("finding source scenes: %w", err)
+ }
+
+ var fileIDs []file.ID
+
+ for _, src := range sources {
+ // TODO - delete generated files as needed
+
+ if err := src.LoadRelationships(ctx, s.Repository); err != nil {
+ return fmt.Errorf("loading scene relationships from %d: %w", src.ID, err)
+ }
+
+ for _, f := range src.Files.List() {
+ fileIDs = append(fileIDs, f.Base().ID)
+ }
+
+ if err := s.mergeSceneMarkers(ctx, dest, src); err != nil {
+ return err
+ }
+ }
+
+ // move files to destination scene
+ if len(fileIDs) > 0 {
+ if err := s.Repository.AssignFiles(ctx, destinationID, fileIDs); err != nil {
+ return fmt.Errorf("moving files to destination scene: %w", err)
+ }
+
+ // if scene didn't already have a primary file, then set it now
+ if dest.PrimaryFileID == nil {
+ scenePartial.PrimaryFileID = &fileIDs[0]
+ } else {
+ // don't allow changing primary file ID from the input values
+ scenePartial.PrimaryFileID = nil
+ }
+ }
+
+ if _, err := s.Repository.UpdatePartial(ctx, destinationID, scenePartial); err != nil {
+ return fmt.Errorf("updating scene: %w", err)
+ }
+
+ // delete old scenes
+ for _, srcID := range sourceIDs {
+ if err := s.Repository.Destroy(ctx, srcID); err != nil {
+ return fmt.Errorf("deleting scene %d: %w", srcID, err)
+ }
+ }
+
+ return nil
+}
+
+func (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src *models.Scene) error {
+ markers, err := s.MarkerRepository.FindBySceneID(ctx, src.ID)
+ if err != nil {
+ return fmt.Errorf("finding scene markers: %w", err)
+ }
+
+ type rename struct {
+ src string
+ dest string
+ }
+
+ var toRename []rename
+
+ destHash := dest.GetHash(s.Config.GetVideoFileNamingAlgorithm())
+
+ for _, m := range markers {
+ srcHash := src.GetHash(s.Config.GetVideoFileNamingAlgorithm())
+
+ // updated the scene id
+ m.SceneID.Int64 = int64(dest.ID)
+
+ if _, err := s.MarkerRepository.Update(ctx, *m); err != nil {
+ return fmt.Errorf("updating scene marker %d: %w", m.ID, err)
+ }
+
+ // move generated files to new location
+ toRename = append(toRename, []rename{
+ {
+ src: s.Paths.SceneMarkers.GetScreenshotPath(srcHash, int(m.Seconds)),
+ dest: s.Paths.SceneMarkers.GetScreenshotPath(destHash, int(m.Seconds)),
+ },
+ {
+ src: s.Paths.SceneMarkers.GetThumbnailPath(srcHash, int(m.Seconds)),
+ dest: s.Paths.SceneMarkers.GetThumbnailPath(destHash, int(m.Seconds)),
+ },
+ {
+ src: s.Paths.SceneMarkers.GetWebpPreviewPath(srcHash, int(m.Seconds)),
+ dest: s.Paths.SceneMarkers.GetWebpPreviewPath(destHash, int(m.Seconds)),
+ },
+ }...)
+ }
+
+ if len(toRename) > 0 {
+ txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
+ // rename the files if they exist
+ for _, e := range toRename {
+ srcExists, _ := fsutil.FileExists(e.src)
+ destExists, _ := fsutil.FileExists(e.dest)
+
+ if srcExists && !destExists {
+ if err := os.Rename(e.src, e.dest); err != nil {
+ logger.Errorf("Error renaming generated marker file from %s to %s: %v", e.src, e.dest, err)
+ }
+ }
+ }
+
+ return nil
+ })
+ }
+
+ return nil
+}
diff --git a/pkg/scene/query.go b/pkg/scene/query.go
index 928270f38..e910f42f0 100644
--- a/pkg/scene/query.go
+++ b/pkg/scene/query.go
@@ -16,6 +16,7 @@ type Queryer interface {
type IDFinder interface {
Find(ctx context.Context, id int) (*models.Scene, error)
+ FindMany(ctx context.Context, ids []int) ([]*models.Scene, error)
}
// QueryOptions returns a SceneQueryOptions populated with the provided filters.
diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go
index 9404a0b88..48cd7dfc9 100644
--- a/pkg/scene/scan.go
+++ b/pkg/scene/scan.go
@@ -20,7 +20,7 @@ var (
type CreatorUpdater interface {
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error)
FindByFingerprints(ctx context.Context, fp []file.Fingerprint) ([]*models.Scene, error)
- Create(ctx context.Context, newScene *models.Scene, fileIDs []file.ID) error
+ Creator
UpdatePartial(ctx context.Context, id int, updatedScene models.ScenePartial) (*models.Scene, error)
AddFileID(ctx context.Context, id int, fileID file.ID) error
models.VideoFileLoader
diff --git a/pkg/scene/screenshot.go b/pkg/scene/screenshot.go
index 13464e16e..8335c53d6 100644
--- a/pkg/scene/screenshot.go
+++ b/pkg/scene/screenshot.go
@@ -32,6 +32,10 @@ type PathsCoverSetter struct {
}
func (ss *PathsCoverSetter) SetScreenshot(scene *models.Scene, imageData []byte) error {
+ // don't set where scene has no file
+ if scene.Path == "" {
+ return nil
+ }
checksum := scene.GetHash(ss.FileNamingAlgorithm)
return SetScreenshot(ss.Paths, checksum, imageData)
}
diff --git a/pkg/scene/service.go b/pkg/scene/service.go
index 8d2e5dc0c..c162858af 100644
--- a/pkg/scene/service.go
+++ b/pkg/scene/service.go
@@ -5,20 +5,55 @@ import (
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/models"
+ "github.com/stashapp/stash/pkg/models/paths"
+ "github.com/stashapp/stash/pkg/plugin"
)
type FinderByFile interface {
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error)
}
+type FileAssigner interface {
+ AssignFiles(ctx context.Context, sceneID int, fileID []file.ID) error
+}
+
+type Creator interface {
+ Create(ctx context.Context, newScene *models.Scene, fileIDs []file.ID) error
+}
+
+type CoverUpdater interface {
+ UpdateCover(ctx context.Context, sceneID int, cover []byte) error
+}
+
+type Config interface {
+ GetVideoFileNamingAlgorithm() models.HashAlgorithm
+}
+
type Repository interface {
+ IDFinder
FinderByFile
+ Creator
+ PartialUpdater
Destroyer
models.VideoFileLoader
+ FileAssigner
+ CoverUpdater
+ models.SceneReader
+}
+
+type MarkerRepository interface {
+ MarkerFinder
+ MarkerDestroyer
+
+ Update(ctx context.Context, updatedObject models.SceneMarker) (*models.SceneMarker, error)
}
type Service struct {
- File file.Store
- Repository Repository
- MarkerDestroyer MarkerDestroyer
+ File file.Store
+ Repository Repository
+ MarkerRepository MarkerRepository
+ PluginCache *plugin.Cache
+
+ Paths *paths.Paths
+ Config Config
}
diff --git a/pkg/scene/update.go b/pkg/scene/update.go
index 420736020..c38597da7 100644
--- a/pkg/scene/update.go
+++ b/pkg/scene/update.go
@@ -6,6 +6,7 @@ import (
"fmt"
"time"
+ "github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
@@ -115,3 +116,27 @@ func AddGallery(ctx context.Context, qb PartialUpdater, o *models.Scene, gallery
})
return err
}
+
+func (s *Service) AssignFile(ctx context.Context, sceneID int, fileID file.ID) error {
+ // ensure file isn't a primary file and that it is a video file
+ f, err := s.File.Find(ctx, fileID)
+ if err != nil {
+ return err
+ }
+
+ ff := f[0]
+ if _, ok := ff.(*file.VideoFile); !ok {
+ return fmt.Errorf("%s is not a video file", ff.Base().Path)
+ }
+
+ isPrimary, err := s.File.IsPrimary(ctx, fileID)
+ if err != nil {
+ return err
+ }
+
+ if isPrimary {
+ return errors.New("cannot reassign primary file")
+ }
+
+ return s.Repository.AssignFiles(ctx, sceneID, []file.ID{fileID})
+}
diff --git a/pkg/scraper/autotag.go b/pkg/scraper/autotag.go
index 5af7b8c70..f81035131 100644
--- a/pkg/scraper/autotag.go
+++ b/pkg/scraper/autotag.go
@@ -97,6 +97,10 @@ func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scen
// populate performers, studio and tags based on scene path
if err := txn.WithTxn(ctx, s.txnManager, func(ctx context.Context) error {
path := scene.Path
+ if path == "" {
+ return nil
+ }
+
performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaScene: %w", err)
diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go
index 1f7820378..869e7b581 100644
--- a/pkg/scraper/stashbox/stash_box.go
+++ b/pkg/scraper/stashbox/stash_box.go
@@ -189,13 +189,22 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, ids []int)
}
func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*scraper.ScrapedScene, error) {
- var ret [][]*scraper.ScrapedScene
- for i := 0; i < len(scenes); i += 40 {
- end := i + 40
- if end > len(scenes) {
- end = len(scenes)
+ var results [][]*scraper.ScrapedScene
+
+ // filter out nils
+ var validScenes [][]*graphql.FingerprintQueryInput
+ for _, s := range scenes {
+ if len(s) > 0 {
+ validScenes = append(validScenes, s)
}
- scenes, err := c.client.FindScenesBySceneFingerprints(ctx, scenes[i:end])
+ }
+
+ for i := 0; i < len(validScenes); i += 40 {
+ end := i + 40
+ if end > len(validScenes) {
+ end = len(validScenes)
+ }
+ scenes, err := c.client.FindScenesBySceneFingerprints(ctx, validScenes[i:end])
if err != nil {
return nil, err
@@ -210,11 +219,22 @@ func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][
}
sceneResults = append(sceneResults, ss)
}
- ret = append(ret, sceneResults)
+ results = append(results, sceneResults)
}
}
- return ret, nil
+ // repopulate the results to be the same order as the input
+ ret := make([][]*scraper.ScrapedScene, len(scenes))
+ upTo := 0
+
+ for i, v := range scenes {
+ if len(v) > 0 {
+ ret[i] = results[upTo]
+ upTo++
+ }
+ }
+
+ return results, nil
}
func (c Client) SubmitStashBoxFingerprints(ctx context.Context, sceneIDs []string, endpoint string) (bool, error) {
diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go
index e9db3589c..29a05698d 100644
--- a/pkg/sqlite/image.go
+++ b/pkg/sqlite/image.go
@@ -746,7 +746,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima
aggregateQuery := qb.newQuery()
if options.Count {
- aggregateQuery.addColumn("COUNT(temp.id) as total")
+ aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
}
// TODO - this doesn't work yet
diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go
index e8555e7db..1ce8e3989 100644
--- a/pkg/sqlite/scene.go
+++ b/pkg/sqlite/scene.go
@@ -975,7 +975,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce
aggregateQuery := qb.newQuery()
if options.Count {
- aggregateQuery.addColumn("COUNT(temp.id) as total")
+ aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
}
if options.TotalDuration {
@@ -1432,6 +1432,22 @@ func (qb *SceneStore) DestroyCover(ctx context.Context, sceneID int) error {
return qb.imageRepository().destroy(ctx, []int{sceneID})
}
+func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []file.ID) error {
+ // assuming a file can only be assigned to a single scene
+ if err := scenesFilesTableMgr.destroyJoins(ctx, fileIDs); err != nil {
+ return err
+ }
+
+ // assign primary only if destination has no files
+ existingFileIDs, err := qb.filesRepository().get(ctx, sceneID)
+ if err != nil {
+ return err
+ }
+
+ firstPrimary := len(existingFileIDs) == 0
+ return scenesFilesTableMgr.insertJoins(ctx, sceneID, firstPrimary, fileIDs)
+}
+
func (qb *SceneStore) moviesRepository() *repository {
return &repository{
tx: qb.tx,
diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go
index 3ace6b813..52033abb4 100644
--- a/pkg/sqlite/scene_test.go
+++ b/pkg/sqlite/scene_test.go
@@ -4077,5 +4077,47 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
})
}
+func TestSceneStore_AssignFiles(t *testing.T) {
+ tests := []struct {
+ name string
+ sceneID int
+ fileID file.ID
+ wantErr bool
+ }{
+ {
+ "valid",
+ sceneIDs[sceneIdx1WithPerformer],
+ sceneFileIDs[sceneIdx1WithStudio],
+ false,
+ },
+ {
+ "invalid file id",
+ sceneIDs[sceneIdx1WithPerformer],
+ invalidFileID,
+ true,
+ },
+ {
+ "invalid scene id",
+ invalidID,
+ sceneFileIDs[sceneIdx1WithStudio],
+ true,
+ },
+ }
+
+ qb := db.Scene
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ withRollbackTxn(func(ctx context.Context) error {
+ if err := qb.AssignFiles(ctx, tt.sceneID, []file.ID{tt.fileID}); (err != nil) != tt.wantErr {
+ t.Errorf("SceneStore.AssignFiles() error = %v, wantErr %v", err, tt.wantErr)
+ }
+
+ return nil
+ })
+ })
+ }
+}
+
// TODO Count
// TODO SizeCount
diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go
index fbb3bbb89..31cdefcf9 100644
--- a/pkg/sqlite/table.go
+++ b/pkg/sqlite/table.go
@@ -529,6 +529,17 @@ func (t *relatedFilesTable) replaceJoins(ctx context.Context, id int, fileIDs []
return t.insertJoins(ctx, id, firstPrimary, fileIDs)
}
+// destroyJoins destroys all entries in the table with the provided fileIDs
+func (t *relatedFilesTable) destroyJoins(ctx context.Context, fileIDs []file.ID) error {
+ q := dialect.Delete(t.table.table).Where(t.table.table.Col("file_id").In(fileIDs))
+
+ if _, err := exec(ctx, q); err != nil {
+ return fmt.Errorf("destroying file joins in %s: %w", t.table.table.GetTable(), err)
+ }
+
+ return nil
+}
+
func (t *relatedFilesTable) setPrimary(ctx context.Context, id int, fileID file.ID) error {
table := t.table.table
diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx
index 247eb3182..184a0dd14 100644
--- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx
+++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx
@@ -566,8 +566,9 @@ export const GalleryEditPanel: React.FC<
})}
onSetScenes(items)}
+ isMulti
/>
diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx
index d92808d98..d48fb015a 100644
--- a/ui/v2.5/src/components/MainNavbar.tsx
+++ b/ui/v2.5/src/components/MainNavbar.tsx
@@ -95,6 +95,7 @@ const allMenuItems: IMenuItem[] = [
href: "/scenes",
icon: faPlayCircle,
hotkey: "g s",
+ userCreatable: true,
},
{
name: "images",
diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx
index 6ef3c2579..2f7c31a75 100644
--- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx
+++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx
@@ -38,6 +38,8 @@ import {
faTag,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
+import { SceneMergeModal } from "../Scenes/SceneMergeDialog";
+import { objectTitle } from "src/core/files";
const CLASSNAME = "duplicate-checker";
@@ -75,6 +77,10 @@ export const SceneDuplicateChecker: React.FC = () => {
},
scene_filter: {
is_missing: "phash",
+ file_count: {
+ modifier: GQL.CriterionModifier.GreaterThan,
+ value: 0,
+ },
},
},
});
@@ -83,6 +89,10 @@ export const SceneDuplicateChecker: React.FC = () => {
GQL.SlimSceneDataFragment[] | null
>(null);
+ const [mergeScenes, setMergeScenes] = useState<
+ { id: string; title: string }[] | undefined
+ >(undefined);
+
if (loading) return ;
if (!data) return ;
@@ -390,8 +400,58 @@ export const SceneDuplicateChecker: React.FC = () => {
);
}
+ function renderMergeDialog() {
+ if (mergeScenes) {
+ return (
+ {
+ setMergeScenes(undefined);
+ if (mergedID) {
+ // refresh
+ refetch();
+ }
+ }}
+ show
+ />
+ );
+ }
+ }
+
+ function onMergeClicked(
+ sceneGroup: GQL.SlimSceneDataFragment[],
+ scene: GQL.SlimSceneDataFragment
+ ) {
+ const selected = scenes.flat().filter((s) => checkedScenes[s.id]);
+
+ // if scenes in this group other than this scene are selected, then only
+ // the selected scenes will be selected as source. Otherwise all other
+ // scenes will be source
+ let srcScenes =
+ selected.filter((s) => {
+ if (s === scene) return false;
+ return sceneGroup.includes(s);
+ }) ?? [];
+
+ if (!srcScenes.length) {
+ srcScenes = sceneGroup.filter((s) => s !== scene);
+ }
+
+ // insert subject scene to the front so that it is considered the destination
+ srcScenes.unshift(scene);
+
+ setMergeScenes(
+ srcScenes.map((s) => {
+ return {
+ id: s.id,
+ title: objectTitle(s),
+ };
+ })
+ );
+ }
+
return (
-
+
{deletingScenes && selectedScenes && (
{
onClose={onDeleteDialogClosed}
/>
)}
+ {renderMergeDialog()}
{maybeRenderEdit()}
@@ -548,6 +609,12 @@ export const SceneDuplicateChecker: React.FC = () => {
>
+
>
diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx
index 8a08d1915..49ccdc92c 100644
--- a/ui/v2.5/src/components/Scenes/SceneCard.tsx
+++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx
@@ -360,6 +360,16 @@ export const SceneCard: React.FC = (
if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`;
}
+
+ return "";
+ }
+
+ function filelessClass() {
+ if (!props.scene.files.length) {
+ return "fileless";
+ }
+
+ return "";
}
const cont = configuration?.interface.continuePlaylistDefault ?? false;
@@ -373,7 +383,7 @@ export const SceneCard: React.FC = (
return (
void;
setTimestamp: (num: number) => void;
queueScenes: QueuedScene[];
onQueueNext: () => void;
@@ -81,7 +80,6 @@ interface IProps {
const ScenePage: React.FC = ({
scene,
- refetch,
setTimestamp,
queueScenes,
onQueueNext,
@@ -260,13 +258,15 @@ const ScenePage: React.FC = ({
- onRescan()}
- >
-
-
+ {!!scene.files.length && (
+ onRescan()}
+ >
+
+
+ )}
= ({
isVisible={activeTabKey === "scene-edit-panel"}
scene={scene}
onDelete={() => setIsDeleteAlertOpen(true)}
- onUpdate={refetch}
/>
@@ -511,7 +510,7 @@ const SceneLoader: React.FC = () => {
const location = useLocation();
const history = useHistory();
const { configuration } = useContext(ConfigurationContext);
- const { data, loading, refetch } = useFindScene(id ?? "");
+ const { data, loading } = useFindScene(id ?? "");
const queryParams = useMemo(
() => queryString.parse(location.search, { decode: false }),
@@ -732,7 +731,6 @@ const SceneLoader: React.FC = () => {
{!loading && scene ? (
{
+ const intl = useIntl();
+
+ // create scene from provided scene id if applicable
+ const queryParams = queryString.parse(location.search);
+
+ const fromSceneID = (queryParams?.from_scene_id ?? "") as string;
+ const { data, loading } = useFindScene(fromSceneID ?? "");
+ const [loadingCoverImage, setLoadingCoverImage] = useState(false);
+ const [coverImage, setCoverImage] = useState(undefined);
+
+ const scene = useMemo(() => {
+ if (data?.findScene) {
+ return {
+ ...data.findScene,
+ paths: undefined,
+ id: undefined,
+ };
+ }
+
+ return {};
+ }, [data?.findScene]);
+
+ useEffect(() => {
+ async function fetchCoverImage() {
+ const srcScene = data?.findScene;
+ if (srcScene?.paths.screenshot) {
+ setLoadingCoverImage(true);
+ const imageData = await ImageUtils.imageToDataURL(
+ srcScene.paths.screenshot
+ );
+ setCoverImage(imageData);
+ setLoadingCoverImage(false);
+ } else {
+ setCoverImage(undefined);
+ }
+ }
+
+ fetchCoverImage();
+ }, [data?.findScene]);
+
+ if (loading || loadingCoverImage) {
+ return ;
+ }
+
+ return (
+
+ );
+};
+
+export default SceneCreate;
diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx
index d8b90cb66..81a8702a9 100644
--- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx
+++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx
@@ -19,6 +19,7 @@ import {
useSceneUpdate,
mutateReloadScrapers,
queryScrapeSceneQueryFragment,
+ mutateCreateScene,
} from "src/core/StashService";
import {
PerformerSelect,
@@ -34,7 +35,8 @@ import useToast from "src/hooks/Toast";
import { ImageUtils, FormUtils, getStashIDs } from "src/utils";
import { MovieSelect } from "src/components/Shared/Select";
import { useFormik } from "formik";
-import { Prompt } from "react-router-dom";
+import { Prompt, useHistory } from "react-router-dom";
+import queryString from "query-string";
import { ConfigurationContext } from "src/hooks/Config";
import { stashboxDisplayName } from "src/utils/stashbox";
import { SceneMovieTable } from "./SceneMovieTable";
@@ -50,19 +52,28 @@ const SceneScrapeDialog = lazy(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazy(() => import("./SceneQueryModal"));
interface IProps {
- scene: GQL.SceneDataFragment;
+ scene: Partial;
+ initialCoverImage?: string;
+ isNew?: boolean;
isVisible: boolean;
- onDelete: () => void;
- onUpdate?: () => void;
+ onDelete?: () => void;
}
export const SceneEditPanel: React.FC = ({
scene,
+ initialCoverImage,
+ isNew = false,
isVisible,
onDelete,
}) => {
const intl = useIntl();
const Toast = useToast();
+ const history = useHistory();
+
+ const queryParams = queryString.parse(location.search);
+
+ const fileID = (queryParams?.file_id ?? "") as string;
+
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
[]
);
@@ -84,15 +95,17 @@ export const SceneEditPanel: React.FC = ({
>();
useEffect(() => {
- setCoverImagePreview(scene.paths.screenshot ?? undefined);
- }, [scene.paths.screenshot]);
+ setCoverImagePreview(
+ initialCoverImage ?? scene.paths?.screenshot ?? undefined
+ );
+ }, [scene.paths?.screenshot, initialCoverImage]);
useEffect(() => {
setGalleries(
- scene.galleries.map((g) => ({
+ scene.galleries?.map((g) => ({
id: g.id,
title: objectTitle(g),
- }))
+ })) ?? []
);
}, [scene.galleries]);
@@ -142,10 +155,10 @@ export const SceneEditPanel: React.FC = ({
return { movie_id: m.movie.id, scene_index: m.scene_index };
}),
tag_ids: (scene.tags ?? []).map((t) => t.id),
- cover_image: undefined,
+ cover_image: initialCoverImage,
stash_ids: getStashIDs(scene.stash_ids),
}),
- [scene]
+ [scene, initialCoverImage]
);
type InputValues = typeof initialValues;
@@ -154,7 +167,7 @@ export const SceneEditPanel: React.FC = ({
initialValues,
enableReinitialize: true,
validationSchema: schema,
- onSubmit: (values) => onSave(getSceneInput(values)),
+ onSubmit: (values) => onSave(values),
});
function setRating(v: number) {
@@ -180,7 +193,9 @@ export const SceneEditPanel: React.FC = ({
formik.handleSubmit();
});
Mousetrap.bind("d d", () => {
- onDelete();
+ if (onDelete) {
+ onDelete();
+ }
});
// numeric keypresses get caught by jwplayer, so blur the element
@@ -234,7 +249,7 @@ export const SceneEditPanel: React.FC = ({
function getSceneInput(input: InputValues): GQL.SceneUpdateInput {
return {
- id: scene.id,
+ id: scene.id!,
...input,
};
}
@@ -256,27 +271,49 @@ export const SceneEditPanel: React.FC = ({
formik.setFieldValue("movies", newMovies);
}
- async function onSave(input: GQL.SceneUpdateInput) {
+ function getCreateValues(values: InputValues): GQL.SceneCreateInput {
+ return {
+ ...values,
+ };
+ }
+
+ async function onSave(input: InputValues) {
setIsLoading(true);
try {
- const result = await updateScene({
- variables: {
- input: {
- ...input,
- rating: input.rating ?? null,
+ if (!isNew) {
+ const updateValues = getSceneInput(input);
+ const result = await updateScene({
+ variables: {
+ input: {
+ ...updateValues,
+ id: scene.id!,
+ rating: input.rating ?? null,
+ },
},
- },
- });
- if (result.data?.sceneUpdate) {
- Toast.success({
- content: intl.formatMessage(
- { id: "toast.updated_entity" },
- { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() }
- ),
});
- // clear the cover image so that it doesn't appear dirty
- formik.resetForm({ values: formik.values });
+ if (result.data?.sceneUpdate) {
+ Toast.success({
+ content: intl.formatMessage(
+ { id: "toast.updated_entity" },
+ {
+ entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(),
+ }
+ ),
+ });
+ }
+ } else {
+ const createValues = getCreateValues(input);
+ const result = await mutateCreateScene({
+ ...createValues,
+ file_ids: fileID ? [fileID as string] : undefined,
+ });
+ if (result.data?.sceneCreate?.id) {
+ history.push(`/scenes/${result.data?.sceneCreate.id}`);
+ }
}
+
+ // clear the cover image so that it doesn't appear dirty
+ formik.resetForm({ values: formik.values });
} catch (e) {
Toast.error(e);
}
@@ -316,7 +353,7 @@ export const SceneEditPanel: React.FC = ({
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
setIsLoading(true);
try {
- const result = await queryScrapeScene(s, scene.id);
+ const result = await queryScrapeScene(s, scene.id!);
if (!result.data || !result.data.scrapeSingleScene?.length) {
Toast.success({
content: "No scenes found",
@@ -399,7 +436,7 @@ export const SceneEditPanel: React.FC = ({
const currentScene = getSceneInput(formik.values);
if (!currentScene.cover_image) {
- currentScene.cover_image = scene.paths.screenshot;
+ currentScene.cover_image = scene.paths!.screenshot;
}
return (
@@ -670,6 +707,24 @@ export const SceneEditPanel: React.FC = ({
);
}
+ const image = useMemo(() => {
+ if (imageEncoding) {
+ return ;
+ }
+
+ if (coverImagePreview) {
+ return (
+
+ );
+ }
+
+ return ;
+ }, [imageEncoding, coverImagePreview, intl]);
+
if (isLoading) return ;
return (
@@ -687,25 +742,29 @@ export const SceneEditPanel: React.FC = ({
-
-
-
-
- {renderScraperMenu()}
- {renderScrapeQueryMenu()}
-
+ {onDelete && (
+
+ )}
+ {!isNew && (
+
+
+ {renderScraperMenu()}
+ {renderScrapeQueryMenu()}
+
+
+ )}
@@ -758,8 +817,9 @@ export const SceneEditPanel: React.FC
= ({
})}
onSetGalleries(items)}
+ isMulti
/>
@@ -918,15 +978,7 @@ export const SceneEditPanel: React.FC = ({
- {imageEncoding ? (
-
- ) : (
-
- )}
+ {image}
void;
onDeleteFile?: () => void;
+ onReassign?: () => void;
loading?: boolean;
}
@@ -22,6 +26,7 @@ const FileInfoPanel: React.FC = (
props: IFileInfoPanelProps
) => {
const intl = useIntl();
+ const history = useHistory();
function renderFileSize() {
const { size, unit } = TextUtils.fileSize(props.file.size);
@@ -47,6 +52,12 @@ const FileInfoPanel: React.FC = (
const phash = props.file.fingerprints.find((f) => f.type === "phash");
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
+ function onSplit() {
+ history.push(
+ `/scenes/new?from_scene_id=${props.sceneID}&file_id=${props.file.id}`
+ );
+ }
+
return (
@@ -122,6 +133,16 @@ const FileInfoPanel: React.FC = (
>
+
+