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< })}