From 4a054ab081c4655404382e9a952ffb48988fcb81 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 14 Nov 2022 16:35:09 +1100 Subject: [PATCH] Support file-less scenes. Add scene split, merge and reassign file (#3006) * Reassign scene file functionality * Implement scene create * Add scene create UI * Add sceneMerge backend support * Add merge scene to UI * Populate split create with scene details * Add merge button to duplicate checker * Handle file-less scenes in marker preview generate * Make unique file name for file-less scene exports * Add o-counter to scene update input * Hide rescan for file-less scenes * Generate heatmap if no speed set on file * Fix count in scene/image queries --- graphql/documents/mutations/scene.graphql | 18 + graphql/schema/schema.graphql | 4 + graphql/schema/types/scene.graphql | 38 + internal/api/changeset_translator.go | 68 +- internal/api/resolver_model_scene.go | 2 + internal/api/resolver_mutation_scene.go | 305 ++++++-- internal/manager/manager.go | 17 +- internal/manager/repository.go | 3 + internal/manager/running_streams.go | 14 +- internal/manager/scene.go | 2 +- internal/manager/task_autotag.go | 5 + internal/manager/task_export.go | 2 +- internal/manager/task_generate.go | 28 +- ...task_generate_interactive_heatmap_speed.go | 2 +- internal/manager/task_generate_markers.go | 27 +- internal/manager/task_generate_preview.go | 4 + internal/manager/task_generate_screenshot.go | 3 + pkg/file/file.go | 3 + pkg/models/jsonschema/scene.go | 6 +- pkg/models/model_joins.go | 28 +- pkg/models/model_scene.go | 1 + pkg/models/paths/paths.go | 4 +- pkg/models/relationships.go | 13 + pkg/models/stash_ids.go | 11 + pkg/models/value.go | 28 + pkg/scene/create.go | 76 ++ pkg/scene/delete.go | 2 +- pkg/scene/merge.go | 144 ++++ pkg/scene/query.go | 1 + pkg/scene/scan.go | 2 +- pkg/scene/screenshot.go | 4 + pkg/scene/service.go | 41 +- pkg/scene/update.go | 25 + pkg/scraper/autotag.go | 4 + pkg/scraper/stashbox/stash_box.go | 36 +- pkg/sqlite/image.go | 2 +- pkg/sqlite/scene.go | 18 +- pkg/sqlite/scene_test.go | 42 ++ pkg/sqlite/table.go | 11 + .../GalleryDetails/GalleryEditPanel.tsx | 3 +- ui/v2.5/src/components/MainNavbar.tsx | 1 + .../SceneDuplicateChecker.tsx | 69 +- ui/v2.5/src/components/Scenes/SceneCard.tsx | 12 +- .../components/Scenes/SceneDetails/Scene.tsx | 22 +- .../Scenes/SceneDetails/SceneCreate.tsx | 74 ++ .../Scenes/SceneDetails/SceneEditPanel.tsx | 162 +++-- .../SceneDetails/SceneFileInfoPanel.tsx | 38 +- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 410 ++++++----- ui/v2.5/src/components/Scenes/SceneList.tsx | 46 ++ .../components/Scenes/SceneMergeDialog.tsx | 662 ++++++++++++++++++ ui/v2.5/src/components/Scenes/Scenes.tsx | 2 + ui/v2.5/src/components/Scenes/styles.scss | 6 +- .../components/Shared/ReassignFilesDialog.tsx | 101 +++ .../src/components/Shared/ScrapeDialog.tsx | 27 +- ui/v2.5/src/components/Shared/Select.tsx | 186 ++++- ui/v2.5/src/components/Tagger/utils.ts | 8 + ui/v2.5/src/core/StashService.ts | 57 ++ ui/v2.5/src/docs/en/Changelog/v0180.md | 3 + ui/v2.5/src/locales/en-GB.json | 15 + ui/v2.5/src/utils/image.tsx | 14 + 60 files changed, 2550 insertions(+), 412 deletions(-) create mode 100644 pkg/scene/create.go create mode 100644 pkg/scene/merge.go create mode 100644 ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx create mode 100644 ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx 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 ( + {intl.formatMessage({ + ); + } + + 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 ? ( - - ) : ( - {intl.formatMessage({ - )} + {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 = ( > + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDestScene(items)} + selected={destScene} + /> + + +
+
+ + ); +}; diff --git a/ui/v2.5/src/components/Scenes/Scenes.tsx b/ui/v2.5/src/components/Scenes/Scenes.tsx index ea2a7befe..97a9ea5c6 100644 --- a/ui/v2.5/src/components/Scenes/Scenes.tsx +++ b/ui/v2.5/src/components/Scenes/Scenes.tsx @@ -8,6 +8,7 @@ import { PersistanceLevel } from "src/hooks/ListHook"; const SceneList = lazy(() => import("./SceneList")); const SceneMarkerList = lazy(() => import("./SceneMarkerList")); const Scene = lazy(() => import("./SceneDetails/Scene")); +const SceneCreate = lazy(() => import("./SceneDetails/SceneCreate")); const Scenes: React.FC = () => { const intl = useIntl(); @@ -30,6 +31,7 @@ const Scenes: React.FC = () => { )} /> + diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 6c95f60c3..660d354b9 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -240,6 +240,10 @@ textarea.scene-description { .scene-card.card { overflow: hidden; padding: 0; + + &.fileless { + background-color: darken($card-bg, 5%); + } } .scene-cover { @@ -476,7 +480,7 @@ input[type="range"].blue-slider { } .rating-stars { - display: inline-block; + display: inline-flex; button { font-size: inherit; diff --git a/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx b/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx new file mode 100644 index 000000000..8a15359b2 --- /dev/null +++ b/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { Modal, SceneSelect } from "src/components/Shared"; +import { useToast } from "src/hooks"; +import { useIntl } from "react-intl"; +import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; +import { Col, Form, Row } from "react-bootstrap"; +import { FormUtils } from "src/utils"; +import { mutateSceneAssignFile } from "src/core/StashService"; + +interface IFile { + id: string; + path: string; +} + +interface IReassignFilesDialogProps { + selected: IFile; + onClose: () => void; +} + +export const ReassignFilesDialog: React.FC = ( + props: IReassignFilesDialogProps +) => { + const [scenes, setScenes] = useState<{ id: string; title: string }[]>([]); + + const intl = useIntl(); + const singularEntity = intl.formatMessage({ id: "file" }); + const pluralEntity = intl.formatMessage({ id: "files" }); + + const header = intl.formatMessage( + { id: "dialogs.reassign_entity_title" }, + { count: 1, singularEntity, pluralEntity } + ); + + const toastMessage = intl.formatMessage( + { id: "toast.reassign_past_tense" }, + { count: 1, singularEntity, pluralEntity } + ); + + const Toast = useToast(); + + // Network state + const [reassigning, setReassigning] = useState(false); + + async function onAccept() { + if (!scenes.length) { + return; + } + + setReassigning(true); + try { + await mutateSceneAssignFile(scenes[0].id, props.selected.id); + Toast.success({ content: toastMessage }); + props.onClose(); + } catch (e) { + Toast.error(e); + props.onClose(); + } + setReassigning(false); + } + + return ( + props.onClose(), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={reassigning} + > +
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.reassign_files.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setScenes(items)} + /> + + +
+
+ ); +}; + +export default ReassignFilesDialog; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx index a6e78a290..57609635e 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx @@ -29,13 +29,17 @@ export class ScrapeResult { public scraped: boolean = false; public useNewValue: boolean = false; - public constructor(originalValue?: T | null, newValue?: T | null) { + public constructor( + originalValue?: T | null, + newValue?: T | null, + useNewValue?: boolean + ) { this.originalValue = originalValue ?? undefined; this.newValue = newValue ?? undefined; const valuesEqual = isEqual(originalValue, newValue); - this.useNewValue = !!this.newValue && !valuesEqual; - this.scraped = this.useNewValue; + this.useNewValue = useNewValue ?? (!!this.newValue && !valuesEqual); + this.scraped = !!this.newValue && !valuesEqual; } public setOriginalValue(value?: T) { @@ -63,7 +67,12 @@ export class ScrapeResult { } } -interface IHasName { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function hasScrapedValues(values: ScrapeResult[]) { + return values.some((r) => r.scraped); +} + +export interface IHasName { name: string | undefined; } @@ -347,6 +356,8 @@ export const ScrapedImageRow: React.FC = (props) => { interface IScrapeDialogProps { title: string; + existingLabel?: string; + scrapedLabel?: string; renderScrapeRows: () => JSX.Element; onClose: (apply?: boolean) => void; } @@ -379,10 +390,14 @@ export const ScrapeDialog: React.FC = ( - + {props.existingLabel ?? ( + + )} - + {props.scrapedLabel ?? ( + + )} diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 4a88a17d1..8b44b864c 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -96,16 +96,13 @@ interface IFilterComponentProps extends IFilterProps { interface IFilterSelectProps extends Omit, "onChange" | "items" | "onCreateOption"> {} -type Gallery = { id: string; title: string }; -interface IGallerySelect { - galleries: Gallery[]; - onSelect: (items: Gallery[]) => void; -} - -type Scene = { id: string; title: string }; -interface ISceneSelect { - scenes: Scene[]; - onSelect: (items: Scene[]) => void; +type TitledObject = { id: string; title: string }; +interface ITitledSelect { + className?: string; + selected: TitledObject[]; + onSelect: (items: TitledObject[]) => void; + isMulti?: boolean; + disabled?: boolean; } const getSelectedItems = (selectedItems: ValueType) => @@ -318,7 +315,7 @@ const FilterSelectComponent = ( ); }; -export const GallerySelect: React.FC = (props) => { +export const GallerySelect: React.FC = (props) => { const [query, setQuery] = useState(""); const { data, loading } = GQL.useFindGalleriesQuery({ skip: query === "", @@ -349,13 +346,14 @@ export const GallerySelect: React.FC = (props) => { ); }; - const options = props.galleries.map((g) => ({ + const options = props.selected.map((g) => ({ value: g.id, label: g.title ?? "Unknown", })); return ( = (props) => { ); }; -export const SceneSelect: React.FC = (props) => { +export const SceneSelect: React.FC = (props) => { const [query, setQuery] = useState(""); const { data, loading } = GQL.useFindScenesQuery({ skip: query === "", @@ -390,7 +388,7 @@ export const SceneSelect: React.FC = (props) => { setQuery(input); }, 500); - const onChange = (selectedItems: ValueType) => { + const onChange = (selectedItems: ValueType) => { const selected = getSelectedItems(selectedItems); props.onSelect( (selected ?? []).map((s) => ({ @@ -400,7 +398,7 @@ export const SceneSelect: React.FC = (props) => { ); }; - const options = props.scenes.map((s) => ({ + const options = props.selected.map((s) => ({ value: s.id, label: s.title, })); @@ -412,10 +410,63 @@ export const SceneSelect: React.FC = (props) => { isLoading={loading} items={items} selectedOptions={options} - isMulti + isMulti={props.isMulti ?? false} placeholder="Search for scene..." noOptionsMessage={query === "" ? null : "No scenes found."} showDropdown={false} + isDisabled={props.disabled} + /> + ); +}; + +export const ImageSelect: React.FC = (props) => { + const [query, setQuery] = useState(""); + const { data, loading } = GQL.useFindImagesQuery({ + skip: query === "", + variables: { + filter: { + q: query, + }, + }, + }); + + const images = data?.findImages.images ?? []; + const items = images.map((s) => ({ + label: objectTitle(s), + value: s.id, + })); + + const onInputChange = debounce((input: string) => { + setQuery(input); + }, 500); + + const onChange = (selectedItems: ValueType) => { + const selected = getSelectedItems(selectedItems); + props.onSelect( + (selected ?? []).map((s) => ({ + id: s.value, + title: s.label, + })) + ); + }; + + const options = props.selected.map((s) => ({ + value: s.id, + label: s.title, + })); + + return ( + ); }; @@ -804,3 +855,106 @@ export const FilterSelect: React.FC = (props) => ) : ( ); + +interface IStringListSelect { + options?: string[]; + value: string[]; +} + +export const StringListSelect: React.FC = ({ + options = [], + value, +}) => { + const translatedOptions = useMemo(() => { + return options.map((o) => { + return { label: o, value: o }; + }); + }, [options]); + const translatedValue = useMemo(() => { + return value.map((o) => { + return { label: o, value: o }; + }); + }, [value]); + + const styles: Partial> = { + option: (base) => ({ + ...base, + color: "#000", + }), + container: (base, props) => ({ + ...base, + zIndex: props.selectProps.isFocused ? 10 : base.zIndex, + }), + multiValueRemove: (base, props) => ({ + ...base, + color: props.selectProps.isFocused ? base.color : "#333333", + }), + }; + + return ( + null, + ...{ DropdownIndicator: () => null }, + ...{ MultiValueRemove: () => null }, + }} + /> + ); +}; diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 6f8f7fdff..53803a06e 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -112,6 +112,14 @@ export function prepareQueryString( } export const parsePath = (filePath: string) => { + if (!filePath) { + return { + paths: [], + file: "", + ext: "", + }; + } + const path = filePath.toLowerCase(); const isWin = /^([a-z]:|\\\\)/.test(path); const normalizedPath = isWin diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 506f2ea88..56d4c7f99 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -507,6 +507,63 @@ export const mutateSceneSetPrimaryFile = (id: string, fileID: string) => update: deleteCache(sceneMutationImpactedQueries), }); +export const mutateSceneAssignFile = (sceneID: string, fileID: string) => + client.mutate({ + mutation: GQL.SceneAssignFileDocument, + variables: { + input: { + scene_id: sceneID, + file_id: fileID, + }, + }, + update: deleteCache([ + ...sceneMutationImpactedQueries, + GQL.FindSceneDocument, + ]), + refetchQueries: getQueryNames([GQL.FindSceneDocument]), + }); + +export const mutateSceneMerge = ( + destination: string, + source: string[], + values: GQL.SceneUpdateInput +) => + client.mutate({ + mutation: GQL.SceneMergeDocument, + variables: { + input: { + source, + destination, + values, + }, + }, + update: (cache) => { + // evict the merged scenes from the cache so that they are reloaded + cache.evict({ + id: cache.identify({ __typename: "Scene", id: destination }), + }); + source.forEach((id) => + cache.evict({ id: cache.identify({ __typename: "Scene", id }) }) + ); + cache.gc(); + + deleteCache([...sceneMutationImpactedQueries, GQL.FindSceneDocument])( + cache + ); + }, + refetchQueries: getQueryNames([GQL.FindSceneDocument]), + }); + +export const mutateCreateScene = (input: GQL.SceneCreateInput) => + client.mutate({ + mutation: GQL.SceneCreateDocument, + variables: { + input, + }, + update: deleteCache(sceneMutationImpactedQueries), + refetchQueries: getQueryNames([GQL.FindSceneDocument]), + }); + const imageMutationImpactedQueries = [ GQL.FindPerformerDocument, GQL.FindPerformersDocument, diff --git a/ui/v2.5/src/docs/en/Changelog/v0180.md b/ui/v2.5/src/docs/en/Changelog/v0180.md index c200074b5..3aba47b88 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0180.md +++ b/ui/v2.5/src/docs/en/Changelog/v0180.md @@ -1,4 +1,7 @@ ### ✨ New Features +* Support creation of scenes without files. ([#3006](https://github.com/stashapp/stash/pull/3006)) +* Added ability to reassign files to other scenes. ([#3006](https://github.com/stashapp/stash/pull/3006)) +* Added ability to split and merge scenes. ([#3006](https://github.com/stashapp/stash/pull/3006)) * Added Director and Studio Code fields to scenes. ([#3051](https://github.com/stashapp/stash/pull/3051)) * Added selector for Country field. ([#1922](https://github.com/stashapp/stash/pull/1922)) * Added tag description filter criterion. ([#3011](https://github.com/stashapp/stash/pull/3011)) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 704ae11c8..3101b17d0 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -67,6 +67,7 @@ "play_selected": "Play selected", "preview": "Preview", "previous_action": "Back", + "reassign": "Reassign", "refresh": "Refresh", "reload_plugins": "Reload plugins", "reload_scrapers": "Reload scrapers", @@ -98,11 +99,13 @@ "set_image": "Set image…", "show": "Show", "show_configuration": "Show Configuration", + "split": "Split", "skip": "Skip", "stop": "Stop", "submit": "Submit", "submit_stash_box": "Submit to Stash-Box", "submit_update": "Submit update", + "swap": "Swap", "tasks": { "clean_confirm_message": "Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.", "dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.", @@ -630,6 +633,7 @@ "developmentVersion": "Development Version", "dialogs": { "aliases_must_be_unique": "aliases must be unique", + "create_new_entity": "Create new {entity}", "delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:", "delete_confirm": "Are you sure you want to delete {entityName}?", "delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}", @@ -665,11 +669,20 @@ "zoom": "Zoom" } }, + "merge": { + "destination": "Destination", + "empty_results": "Destination field values will be unchanged.", + "source": "Source" + }, "merge_tags": { "destination": "Destination", "source": "Source" }, "overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?", + "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}", + "reassign_files": { + "destination": "Reassign to" + }, "scene_gen": { "force_transcodes": "Force Transcode generation", "force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.", @@ -1048,8 +1061,10 @@ "default_filter_set": "Default filter set", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Generating screenshot…", + "merged_scenes": "Merged scenes", "merged_tags": "Merged tags", "rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", + "reassign_past_tense": "File reassigned", "removed_entity": "Removed {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "saved_entity": "Saved {entity}", "started_auto_tagging": "Started auto tagging", diff --git a/ui/v2.5/src/utils/image.tsx b/ui/v2.5/src/utils/image.tsx index 3bbdaecc2..484c5d055 100644 --- a/ui/v2.5/src/utils/image.tsx +++ b/ui/v2.5/src/utils/image.tsx @@ -53,8 +53,22 @@ const usePasteImage = ( return false; }; +const imageToDataURL = async (url: string) => { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result as string); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +}; + const Image = { onImageChange, usePasteImage, + imageToDataURL, }; export default Image;