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
This commit is contained in:
WithoutPants 2022-11-14 16:35:09 +11:00 committed by GitHub
parent d0b0be4dd4
commit 4a054ab081
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 2550 additions and 412 deletions

View file

@ -1,3 +1,11 @@
mutation SceneCreate(
$input: SceneCreateInput!) {
sceneCreate(input: $input) {
...SceneData
}
}
mutation SceneUpdate( mutation SceneUpdate(
$input: SceneUpdateInput!) { $input: SceneUpdateInput!) {
@ -43,3 +51,13 @@ mutation ScenesDestroy($ids: [ID!]!, $delete_file: Boolean, $delete_generated :
mutation SceneGenerateScreenshot($id: ID!, $at: Float) { mutation SceneGenerateScreenshot($id: ID!, $at: Float) {
sceneGenerateScreenshot(id: $id, at: $at) sceneGenerateScreenshot(id: $id, at: $at)
} }
mutation SceneAssignFile($input: AssignSceneFileInput!) {
sceneAssignFile(input: $input)
}
mutation SceneMerge($input: SceneMergeInput!) {
sceneMerge(input: $input) {
id
}
}

View file

@ -162,7 +162,9 @@ type Mutation {
setup(input: SetupInput!): Boolean! setup(input: SetupInput!): Boolean!
migrate(input: MigrateInput!): Boolean! migrate(input: MigrateInput!): Boolean!
sceneCreate(input: SceneCreateInput!): Scene
sceneUpdate(input: SceneUpdateInput!): Scene sceneUpdate(input: SceneUpdateInput!): Scene
sceneMerge(input: SceneMergeInput!): Scene
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
sceneDestroy(input: SceneDestroyInput!): Boolean! sceneDestroy(input: SceneDestroyInput!): Boolean!
scenesDestroy(input: ScenesDestroyInput!): Boolean! scenesDestroy(input: ScenesDestroyInput!): Boolean!
@ -182,6 +184,8 @@ type Mutation {
sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker
sceneMarkerDestroy(id: ID!): Boolean! sceneMarkerDestroy(id: ID!): Boolean!
sceneAssignFile(input: AssignSceneFileInput!): Boolean!
imageUpdate(input: ImageUpdateInput!): Image imageUpdate(input: ImageUpdateInput!): Image
bulkImageUpdate(input: BulkImageUpdateInput!): [Image!] bulkImageUpdate(input: BulkImageUpdateInput!): [Image!]
imageDestroy(input: ImageDestroyInput!): Boolean! imageDestroy(input: ImageDestroyInput!): Boolean!

View file

@ -74,6 +74,29 @@ input SceneMovieInput {
scene_index: Int 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 { input SceneUpdateInput {
clientMutationId: String clientMutationId: String
id: ID! id: ID!
@ -84,6 +107,7 @@ input SceneUpdateInput {
url: String url: String
date: String date: String
rating: Int rating: Int
o_counter: Int
organized: Boolean organized: Boolean
studio_id: ID studio_id: ID
gallery_ids: [ID!] gallery_ids: [ID!]
@ -190,3 +214,17 @@ type SceneStreamEndpoint {
mime_type: String mime_type: String
label: 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
}

View file

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models" "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{} { 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) args := getArgumentMap(ctx)
input := args[updateInputField] // field can be qualified
var ret map[string]interface{} fields := strings.Split(field, ".")
if input != nil {
ret, _ = input.(map[string]interface{}) 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 { if currArgs != nil {
ret = make(map[string]interface{}) return currArgs
} }
return ret return make(map[string]interface{})
} }
func getUpdateInputMaps(ctx context.Context) []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 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 { func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString {
if !t.hasField(field) { if !t.hasField(field) {
return models.OptionalString{} return models.OptionalString{}
@ -128,6 +153,27 @@ func (t changesetTranslator) optionalDate(value *string, field string) models.Op
return models.NewOptionalDate(models.NewDate(*value)) 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 { func (t changesetTranslator) nullInt64(value *int, field string) *sql.NullInt64 {
if !t.hasField(field) { if !t.hasField(field) {
return nil return nil
@ -185,6 +231,14 @@ func (t changesetTranslator) optionalIntFromString(value *string, field string)
return models.NewOptionalInt(vv), nil 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 { func (t changesetTranslator) optionalBool(value *bool, field string) models.OptionalBool {
if !t.hasField(field) { if !t.hasField(field) {
return models.OptionalBool{} return models.OptionalBool{}

View file

@ -29,6 +29,8 @@ func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (
obj.Files.SetPrimary(ret) obj.Files.SetPrimary(ret)
return ret, nil return ret, nil
} else {
_ = obj.LoadPrimaryFile(ctx, r.repository.File)
} }
return nil, nil return nil, nil

View file

@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"time" "time"
@ -30,6 +31,79 @@ func (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Sc
return ret, nil 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) { func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (ret *models.Scene, err error) {
translator := changesetTranslator{ translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx), inputMap: getUpdateInputMap(ctx),
@ -90,26 +164,7 @@ func (r *mutationResolver) ScenesUpdate(ctx context.Context, input []*models.Sce
return newRet, nil return newRet, nil
} }
func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) { func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTranslator) (*models.ScenePartial, 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 := models.NewScenePartial() updatedScene := models.NewScenePartial()
updatedScene.Title = translator.optionalString(input.Title, "title") updatedScene.Title = translator.optionalString(input.Title, "title")
updatedScene.Code = translator.optionalString(input.Code, "code") 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.URL = translator.optionalString(input.URL, "url")
updatedScene.Date = translator.optionalDate(input.Date, "date") updatedScene.Date = translator.optionalDate(input.Date, "date")
updatedScene.Rating = translator.optionalInt(input.Rating, "rating") 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") updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) 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) converted := file.ID(primaryFileID)
updatedScene.PrimaryFileID = &converted 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") { 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 != "" { if input.CoverImage != nil && *input.CoverImage != "" {
var err error var err error
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage) coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
// update cover table if err := r.sceneUpdateCoverImage(ctx, s, coverImageData); err != nil {
if len(coverImageData) > 0 { return nil, err
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
}
} }
return s, nil 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) { func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneUpdateInput) ([]*models.Scene, error) {
sceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids) sceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
if err != nil { if err != nil {
@ -486,6 +581,84 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
return true, nil 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) { func (r *mutationResolver) getSceneMarker(ctx context.Context, id int) (ret *models.SceneMarker, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.SceneMarker.Find(ctx, id) ret, err = r.repository.SceneMarker.Find(ctx, id)

View file

@ -169,6 +169,9 @@ func initialize() error {
db := sqlite.NewDatabase() db := sqlite.NewDatabase()
// start with empty paths
emptyPaths := paths.Paths{}
instance = &Manager{ instance = &Manager{
Config: cfg, Config: cfg,
Logger: l, Logger: l,
@ -178,14 +181,18 @@ func initialize() error {
Database: db, Database: db,
Repository: sqliteRepository(db), Repository: sqliteRepository(db),
Paths: &emptyPaths,
scanSubs: &subscriptionManager{}, scanSubs: &subscriptionManager{},
} }
instance.SceneService = &scene.Service{ instance.SceneService = &scene.Service{
File: db.File, File: db.File,
Repository: db.Scene, Repository: db.Scene,
MarkerDestroyer: instance.Repository.SceneMarker, MarkerRepository: instance.Repository.SceneMarker,
PluginCache: instance.PluginCache,
Paths: instance.Paths,
Config: cfg,
} }
instance.ImageService = &image.Service{ 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) 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.RefreshConfig()
s.SessionStore = session.NewStore(s.Config) s.SessionStore = session.NewStore(s.Config)
s.PluginCache.RegisterSessionStore(s.SessionStore) s.PluginCache.RegisterSessionStore(s.SessionStore)
@ -518,7 +525,7 @@ func (s *Manager) initScraperCache() *scraper.Cache {
} }
func (s *Manager) RefreshConfig() { func (s *Manager) RefreshConfig() {
s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
config := s.Config config := s.Config
if config.Validate() == nil { if config.Validate() == nil {
if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil { if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil {

View file

@ -92,6 +92,9 @@ func sqliteRepository(d *sqlite.Database) Repository {
} }
type SceneService interface { 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 Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
} }

View file

@ -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) { func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) {
const defaultSceneImage = "scene/scene.svg" 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 // fall back to the scene image blob if the file isn't present
screenshotExists, _ := fsutil.FileExists(filepath) screenshotExists, _ := fsutil.FileExists(filepath)
if screenshotExists { if screenshotExists {
http.ServeFile(w, r, filepath) http.ServeFile(w, r, filepath)
return return
}
} }
var cover []byte var cover []byte

View file

@ -79,7 +79,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea
pf := scene.Files.Primary() pf := scene.Files.Primary()
if pf == nil { if pf == nil {
return nil, fmt.Errorf("nil file") return nil, nil
} }
var ret []*SceneStreamEndpoint var ret []*SceneStreamEndpoint

View file

@ -697,6 +697,11 @@ func (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
r := t.txnManager r := t.txnManager
if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error {
if t.scene.Path == "" {
// nothing to do
return nil
}
if t.performers { if t.performers {
if err := autotag.ScenePerformers(ctx, t.scene, r.Scene, r.Performer, t.cache); err != nil { 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) return fmt.Errorf("error tagging scene performers for %s: %v", t.scene.DisplayName(), err)

View file

@ -583,7 +583,7 @@ func exportScene(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models
basename := filepath.Base(s.Path) basename := filepath.Base(s.Path)
hash := s.OSHash hash := s.OSHash
fn := newSceneJSON.Filename(basename, hash) fn := newSceneJSON.Filename(s.ID, basename, hash)
if err := t.json.saveScene(fn, newSceneJSON); err != nil { if err := t.json.saveScene(fn, newSceneJSON); err != nil {
logger.Errorf("[scenes] <%s> failed to save json: %s", sceneHash, err.Error()) logger.Errorf("[scenes] <%s> failed to save json: %s", sceneHash, err.Error())

View file

@ -295,21 +295,23 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
generator: g, generator: g,
} }
sceneHash := scene.GetHash(task.fileNamingAlgorithm) if task.required() {
addTask := false sceneHash := scene.GetHash(task.fileNamingAlgorithm)
if j.overwrite || !task.doesVideoPreviewExist(sceneHash) { addTask := false
totals.previews++ if j.overwrite || !task.doesVideoPreviewExist(sceneHash) {
addTask = true totals.previews++
} addTask = true
}
if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist(sceneHash)) { if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist(sceneHash)) {
totals.imagePreviews++ totals.imagePreviews++
addTask = true addTask = true
} }
if addTask { if addTask {
totals.tasks++ totals.tasks++
queue <- task queue <- task
}
} }
} }

View file

@ -58,7 +58,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) shouldGenerate() bool {
return false return false
} }
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) 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 { func (t *GenerateInteractiveHeatmapSpeedTask) doesHeatmapExist(sceneChecksum string) bool {

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"path/filepath" "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/fsutil"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "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 { if err := t.TxnManager.WithTxn(ctx, func(ctx context.Context) error {
var err error var err error
scene, err = t.TxnManager.Scene.Find(ctx, int(t.Marker.SceneID.Int64)) 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 return err
}); err != nil { }); err != nil {
logger.Errorf("error finding scene for marker: %s", err.Error()) logger.Errorf("error finding scene for marker: %s", err.Error())
@ -56,10 +60,10 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) {
return return
} }
ffprobe := instance.FFProbe videoFile := scene.Files.Primary()
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
if err != nil { if videoFile == nil {
logger.Errorf("error reading video file: %s", err.Error()) // nothing to do
return return
} }
@ -78,14 +82,9 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) {
return return
} }
if len(sceneMarkers) == 0 { videoFile := t.Scene.Files.Primary()
return
}
ffprobe := instance.FFProbe if len(sceneMarkers) == 0 || videoFile == nil {
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
if err != nil {
logger.Errorf("error reading video file: %s", err.Error())
return 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) sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
seconds := int(sceneMarker.Seconds) seconds := int(sceneMarker.Seconds)
@ -139,7 +138,7 @@ func (t *GenerateMarkersTask) markersNeeded(ctx context.Context) int {
return 0 return 0
} }
if len(sceneMarkers) == 0 { if len(sceneMarkers) == 0 || t.Scene.Files.Primary() == nil {
return 0 return 0
} }

View file

@ -73,6 +73,10 @@ func (t GeneratePreviewTask) generateWebp(videoChecksum string) error {
} }
func (t GeneratePreviewTask) required() bool { func (t GeneratePreviewTask) required() bool {
if t.Scene.Path == "" {
return false
}
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
videoExists := t.doesVideoPreviewExist(sceneHash) videoExists := t.doesVideoPreviewExist(sceneHash)
imageExists := !t.ImagePreview || t.doesImagePreviewExist(sceneHash) imageExists := !t.ImagePreview || t.doesImagePreviewExist(sceneHash)

View file

@ -23,6 +23,9 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) {
scenePath := t.Scene.Path scenePath := t.Scene.Path
videoFile := t.Scene.Files.Primary() videoFile := t.Scene.Files.Primary()
if videoFile == nil {
return
}
var at float64 var at float64
if t.ScreenshotAt == nil { if t.ScreenshotAt == nil {

View file

@ -154,6 +154,7 @@ type Finder interface {
// Getter provides methods to find Files. // Getter provides methods to find Files.
type Getter interface { type Getter interface {
Finder
FindByPath(ctx context.Context, path string) (File, error) FindByPath(ctx context.Context, path string) (File, error)
FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error) FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error)
FindByZipFileID(ctx context.Context, zipFileID ID) ([]File, error) FindByZipFileID(ctx context.Context, zipFileID ID) ([]File, error)
@ -190,6 +191,8 @@ type Store interface {
Creator Creator
Updater Updater
Destroyer Destroyer
IsPrimary(ctx context.Context, fileID ID) (bool, error)
} }
// Decorator wraps the Decorate method to add additional functionality while scanning files. // Decorator wraps the Decorate method to add additional functionality while scanning files.

View file

@ -3,6 +3,7 @@ package jsonschema
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
@ -60,7 +61,7 @@ type Scene struct {
StashIDs []models.StashID `json:"stash_ids,omitempty"` 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) ret := fsutil.SanitiseBasename(s.Title)
if ret == "" { if ret == "" {
ret = basename ret = basename
@ -68,6 +69,9 @@ func (s Scene) Filename(basename string, hash string) string {
if hash != "" { if hash != "" {
ret += "." + hash ret += "." + hash
} else {
// scenes may have no file and therefore no hash
ret += "." + strconv.Itoa(id)
} }
return ret + ".json" return ret + ".json"

View file

@ -41,21 +41,43 @@ func (u *UpdateMovieIDs) SceneMovieInputs() []*SceneMovieInput {
return ret 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) { func UpdateMovieIDsFromInput(i []*SceneMovieInput) (*UpdateMovieIDs, error) {
ret := &UpdateMovieIDs{ ret := &UpdateMovieIDs{
Mode: RelationshipUpdateModeSet, 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) mID, err := strconv.Atoi(v.MovieID)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid movie ID: %s", v.MovieID) return nil, fmt.Errorf("invalid movie ID: %s", v.MovieID)
} }
ret.Movies = append(ret.Movies, MoviesScenes{ ret[i] = MoviesScenes{
MovieID: mID, MovieID: mID,
SceneIndex: v.SceneIndex, SceneIndex: v.SceneIndex,
}) }
} }
return ret, nil return ret, nil

View file

@ -177,6 +177,7 @@ type SceneUpdateInput struct {
URL *string `json:"url"` URL *string `json:"url"`
Date *string `json:"date"` Date *string `json:"date"`
Rating *int `json:"rating"` Rating *int `json:"rating"`
OCounter *int `json:"o_counter"`
Organized *bool `json:"organized"` Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"` StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"` GalleryIds []string `json:"gallery_ids"`

View file

@ -13,13 +13,13 @@ type Paths struct {
SceneMarkers *sceneMarkerPaths SceneMarkers *sceneMarkerPaths
} }
func NewPaths(generatedPath string) *Paths { func NewPaths(generatedPath string) Paths {
p := Paths{} p := Paths{}
p.Generated = newGeneratedPaths(generatedPath) p.Generated = newGeneratedPaths(generatedPath)
p.Scene = newScenePaths(p) p.Scene = newScenePaths(p)
p.SceneMarkers = newSceneMarkerPaths(p) p.SceneMarkers = newSceneMarkerPaths(p)
return &p return p
} }
func GetStashHomeDirectory() string { func GetStashHomeDirectory() string {

View file

@ -138,6 +138,19 @@ func (r *RelatedMovies) Add(movies ...MoviesScenes) {
r.list = append(r.list, movies...) 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 { func (r *RelatedMovies) load(fn func() ([]MoviesScenes, error)) error {
if r.Loaded() { if r.Loaded() {
return nil return nil

View file

@ -9,3 +9,14 @@ type UpdateStashIDs struct {
StashIDs []StashID `json:"stash_ids"` StashIDs []StashID `json:"stash_ids"`
Mode RelationshipUpdateMode `json:"mode"` 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)
}

View file

@ -23,6 +23,13 @@ func (o *OptionalString) Ptr() *string {
return &v 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. // NewOptionalString returns a new OptionalString with the given value.
func NewOptionalString(v string) OptionalString { func NewOptionalString(v string) OptionalString {
return OptionalString{v, false, true} return OptionalString{v, false, true}
@ -58,6 +65,13 @@ func (o *OptionalInt) Ptr() *int {
return &v 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. // NewOptionalInt returns a new OptionalInt with the given value.
func NewOptionalInt(v int) OptionalInt { func NewOptionalInt(v int) OptionalInt {
return OptionalInt{v, false, true} return OptionalInt{v, false, true}
@ -138,6 +152,13 @@ func (o *OptionalBool) Ptr() *bool {
return &v 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. // NewOptionalBool returns a new OptionalBool with the given value.
func NewOptionalBool(v bool) OptionalBool { func NewOptionalBool(v bool) OptionalBool {
return OptionalBool{v, false, true} return OptionalBool{v, false, true}
@ -200,6 +221,13 @@ func NewOptionalDate(v Date) OptionalDate {
return OptionalDate{v, false, true} 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. // NewOptionalBoolPtr returns a new OptionalDate with the given value.
// If the value is nil, the returned OptionalDate will be set and null. // If the value is nil, the returned OptionalDate will be set and null.
func NewOptionalDatePtr(v *Date) OptionalDate { func NewOptionalDatePtr(v *Date) OptionalDate {

76
pkg/scene/create.go Normal file
View file

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

View file

@ -128,7 +128,7 @@ type MarkerDestroyer interface {
// Destroy deletes a scene and its associated relationships from the // Destroy deletes a scene and its associated relationships from the
// database. // database.
func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error { 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) markers, err := mqb.FindBySceneID(ctx, scene.ID)
if err != nil { if err != nil {
return err return err

144
pkg/scene/merge.go Normal file
View file

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

View file

@ -16,6 +16,7 @@ type Queryer interface {
type IDFinder interface { type IDFinder interface {
Find(ctx context.Context, id int) (*models.Scene, error) 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. // QueryOptions returns a SceneQueryOptions populated with the provided filters.

View file

@ -20,7 +20,7 @@ var (
type CreatorUpdater interface { type CreatorUpdater interface {
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error) FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error)
FindByFingerprints(ctx context.Context, fp []file.Fingerprint) ([]*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) UpdatePartial(ctx context.Context, id int, updatedScene models.ScenePartial) (*models.Scene, error)
AddFileID(ctx context.Context, id int, fileID file.ID) error AddFileID(ctx context.Context, id int, fileID file.ID) error
models.VideoFileLoader models.VideoFileLoader

View file

@ -32,6 +32,10 @@ type PathsCoverSetter struct {
} }
func (ss *PathsCoverSetter) SetScreenshot(scene *models.Scene, imageData []byte) error { 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) checksum := scene.GetHash(ss.FileNamingAlgorithm)
return SetScreenshot(ss.Paths, checksum, imageData) return SetScreenshot(ss.Paths, checksum, imageData)
} }

View file

@ -5,20 +5,55 @@ import (
"github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/plugin"
) )
type FinderByFile interface { type FinderByFile interface {
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error) 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 { type Repository interface {
IDFinder
FinderByFile FinderByFile
Creator
PartialUpdater
Destroyer Destroyer
models.VideoFileLoader models.VideoFileLoader
FileAssigner
CoverUpdater
models.SceneReader
}
type MarkerRepository interface {
MarkerFinder
MarkerDestroyer
Update(ctx context.Context, updatedObject models.SceneMarker) (*models.SceneMarker, error)
} }
type Service struct { type Service struct {
File file.Store File file.Store
Repository Repository Repository Repository
MarkerDestroyer MarkerDestroyer MarkerRepository MarkerRepository
PluginCache *plugin.Cache
Paths *paths.Paths
Config Config
} }

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
@ -115,3 +116,27 @@ func AddGallery(ctx context.Context, qb PartialUpdater, o *models.Scene, gallery
}) })
return err 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})
}

View file

@ -97,6 +97,10 @@ func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scen
// populate performers, studio and tags based on scene path // populate performers, studio and tags based on scene path
if err := txn.WithTxn(ctx, s.txnManager, func(ctx context.Context) error { if err := txn.WithTxn(ctx, s.txnManager, func(ctx context.Context) error {
path := scene.Path path := scene.Path
if path == "" {
return nil
}
performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt) performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt)
if err != nil { if err != nil {
return fmt.Errorf("autotag scraper viaScene: %w", err) return fmt.Errorf("autotag scraper viaScene: %w", err)

View file

@ -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) { func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*scraper.ScrapedScene, error) {
var ret [][]*scraper.ScrapedScene var results [][]*scraper.ScrapedScene
for i := 0; i < len(scenes); i += 40 {
end := i + 40 // filter out nils
if end > len(scenes) { var validScenes [][]*graphql.FingerprintQueryInput
end = len(scenes) 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 { if err != nil {
return nil, err return nil, err
@ -210,11 +219,22 @@ func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][
} }
sceneResults = append(sceneResults, ss) 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) { func (c Client) SubmitStashBoxFingerprints(ctx context.Context, sceneIDs []string, endpoint string) (bool, error) {

View file

@ -746,7 +746,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima
aggregateQuery := qb.newQuery() aggregateQuery := qb.newQuery()
if options.Count { if options.Count {
aggregateQuery.addColumn("COUNT(temp.id) as total") aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
} }
// TODO - this doesn't work yet // TODO - this doesn't work yet

View file

@ -975,7 +975,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce
aggregateQuery := qb.newQuery() aggregateQuery := qb.newQuery()
if options.Count { if options.Count {
aggregateQuery.addColumn("COUNT(temp.id) as total") aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
} }
if options.TotalDuration { if options.TotalDuration {
@ -1432,6 +1432,22 @@ func (qb *SceneStore) DestroyCover(ctx context.Context, sceneID int) error {
return qb.imageRepository().destroy(ctx, []int{sceneID}) 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 { func (qb *SceneStore) moviesRepository() *repository {
return &repository{ return &repository{
tx: qb.tx, tx: qb.tx,

View file

@ -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 Count
// TODO SizeCount // TODO SizeCount

View file

@ -529,6 +529,17 @@ func (t *relatedFilesTable) replaceJoins(ctx context.Context, id int, fileIDs []
return t.insertJoins(ctx, id, firstPrimary, 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 { func (t *relatedFilesTable) setPrimary(ctx context.Context, id int, fileID file.ID) error {
table := t.table.table table := t.table.table

View file

@ -566,8 +566,9 @@ export const GalleryEditPanel: React.FC<
})} })}
<Col sm={9} xl={12}> <Col sm={9} xl={12}>
<SceneSelect <SceneSelect
scenes={scenes} selected={scenes}
onSelect={(items) => onSetScenes(items)} onSelect={(items) => onSetScenes(items)}
isMulti
/> />
</Col> </Col>
</Form.Group> </Form.Group>

View file

@ -95,6 +95,7 @@ const allMenuItems: IMenuItem[] = [
href: "/scenes", href: "/scenes",
icon: faPlayCircle, icon: faPlayCircle,
hotkey: "g s", hotkey: "g s",
userCreatable: true,
}, },
{ {
name: "images", name: "images",

View file

@ -38,6 +38,8 @@ import {
faTag, faTag,
faTrash, faTrash,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { SceneMergeModal } from "../Scenes/SceneMergeDialog";
import { objectTitle } from "src/core/files";
const CLASSNAME = "duplicate-checker"; const CLASSNAME = "duplicate-checker";
@ -75,6 +77,10 @@ export const SceneDuplicateChecker: React.FC = () => {
}, },
scene_filter: { scene_filter: {
is_missing: "phash", is_missing: "phash",
file_count: {
modifier: GQL.CriterionModifier.GreaterThan,
value: 0,
},
}, },
}, },
}); });
@ -83,6 +89,10 @@ export const SceneDuplicateChecker: React.FC = () => {
GQL.SlimSceneDataFragment[] | null GQL.SlimSceneDataFragment[] | null
>(null); >(null);
const [mergeScenes, setMergeScenes] = useState<
{ id: string; title: string }[] | undefined
>(undefined);
if (loading) return <LoadingIndicator />; if (loading) return <LoadingIndicator />;
if (!data) return <ErrorMessage error="Error searching for duplicates." />; if (!data) return <ErrorMessage error="Error searching for duplicates." />;
@ -390,8 +400,58 @@ export const SceneDuplicateChecker: React.FC = () => {
); );
} }
function renderMergeDialog() {
if (mergeScenes) {
return (
<SceneMergeModal
scenes={mergeScenes}
onClose={(mergedID?: string) => {
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 ( return (
<Card id="scene-duplicate-checker" className="col col-xl-10 mx-auto"> <Card id="scene-duplicate-checker" className="col col-xl-12 mx-auto">
<div className={CLASSNAME}> <div className={CLASSNAME}>
{deletingScenes && selectedScenes && ( {deletingScenes && selectedScenes && (
<DeleteScenesDialog <DeleteScenesDialog
@ -399,6 +459,7 @@ export const SceneDuplicateChecker: React.FC = () => {
onClose={onDeleteDialogClosed} onClose={onDeleteDialogClosed}
/> />
)} )}
{renderMergeDialog()}
{maybeRenderEdit()} {maybeRenderEdit()}
<h4> <h4>
<FormattedMessage id="dupe_check.title" /> <FormattedMessage id="dupe_check.title" />
@ -548,6 +609,12 @@ export const SceneDuplicateChecker: React.FC = () => {
> >
<FormattedMessage id="actions.delete" /> <FormattedMessage id="actions.delete" />
</Button> </Button>
<Button
className="edit-button"
onClick={() => onMergeClicked(group, scene)}
>
<FormattedMessage id="actions.merge" />
</Button>
</td> </td>
</tr> </tr>
</> </>

View file

@ -360,6 +360,16 @@ export const SceneCard: React.FC<ISceneCardProps> = (
if (!props.compact && props.zoomIndex !== undefined) { if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`; return `zoom-${props.zoomIndex}`;
} }
return "";
}
function filelessClass() {
if (!props.scene.files.length) {
return "fileless";
}
return "";
} }
const cont = configuration?.interface.continuePlaylistDefault ?? false; const cont = configuration?.interface.continuePlaylistDefault ?? false;
@ -373,7 +383,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
return ( return (
<GridCard <GridCard
className={`scene-card ${zoomIndex()}`} className={`scene-card ${zoomIndex()} ${filelessClass()}`}
url={sceneLink} url={sceneLink}
title={objectTitle(props.scene)} title={objectTitle(props.scene)}
linkClassName="scene-card-link" linkClassName="scene-card-link"

View file

@ -61,7 +61,6 @@ import { objectPath, objectTitle } from "src/core/files";
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: GQL.SceneDataFragment;
refetch: () => void;
setTimestamp: (num: number) => void; setTimestamp: (num: number) => void;
queueScenes: QueuedScene[]; queueScenes: QueuedScene[];
onQueueNext: () => void; onQueueNext: () => void;
@ -81,7 +80,6 @@ interface IProps {
const ScenePage: React.FC<IProps> = ({ const ScenePage: React.FC<IProps> = ({
scene, scene,
refetch,
setTimestamp, setTimestamp,
queueScenes, queueScenes,
onQueueNext, onQueueNext,
@ -260,13 +258,15 @@ const ScenePage: React.FC<IProps> = ({
<Icon icon={faEllipsisV} /> <Icon icon={faEllipsisV} />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className="bg-secondary text-white"> <Dropdown.Menu className="bg-secondary text-white">
<Dropdown.Item {!!scene.files.length && (
key="rescan" <Dropdown.Item
className="bg-secondary text-white" key="rescan"
onClick={() => onRescan()} className="bg-secondary text-white"
> onClick={() => onRescan()}
<FormattedMessage id="actions.rescan" /> >
</Dropdown.Item> <FormattedMessage id="actions.rescan" />
</Dropdown.Item>
)}
<Dropdown.Item <Dropdown.Item
key="generate" key="generate"
className="bg-secondary text-white" className="bg-secondary text-white"
@ -449,7 +449,6 @@ const ScenePage: React.FC<IProps> = ({
isVisible={activeTabKey === "scene-edit-panel"} isVisible={activeTabKey === "scene-edit-panel"}
scene={scene} scene={scene}
onDelete={() => setIsDeleteAlertOpen(true)} onDelete={() => setIsDeleteAlertOpen(true)}
onUpdate={refetch}
/> />
</Tab.Pane> </Tab.Pane>
</Tab.Content> </Tab.Content>
@ -511,7 +510,7 @@ const SceneLoader: React.FC = () => {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const { configuration } = useContext(ConfigurationContext); const { configuration } = useContext(ConfigurationContext);
const { data, loading, refetch } = useFindScene(id ?? ""); const { data, loading } = useFindScene(id ?? "");
const queryParams = useMemo( const queryParams = useMemo(
() => queryString.parse(location.search, { decode: false }), () => queryString.parse(location.search, { decode: false }),
@ -732,7 +731,6 @@ const SceneLoader: React.FC = () => {
{!loading && scene ? ( {!loading && scene ? (
<ScenePage <ScenePage
scene={scene} scene={scene}
refetch={refetch}
setTimestamp={setTimestamp} setTimestamp={setTimestamp}
queueScenes={queueScenes ?? []} queueScenes={queueScenes ?? []}
queueStart={queueStart} queueStart={queueStart}

View file

@ -0,0 +1,74 @@
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { SceneEditPanel } from "./SceneEditPanel";
import queryString from "query-string";
import { useFindScene } from "src/core/StashService";
import { ImageUtils } from "src/utils";
import { LoadingIndicator } from "src/components/Shared";
const SceneCreate: React.FC = () => {
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<string | undefined>(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 <LoadingIndicator />;
}
return (
<div className="row new-view justify-content-center" id="create-scene-page">
<div className="col-md-8">
<h2>
<FormattedMessage
id="actions.create_entity"
values={{ entityType: intl.formatMessage({ id: "scene" }) }}
/>
</h2>
<SceneEditPanel
scene={scene}
initialCoverImage={coverImage}
isVisible
isNew
/>
</div>
</div>
);
};
export default SceneCreate;

View file

@ -19,6 +19,7 @@ import {
useSceneUpdate, useSceneUpdate,
mutateReloadScrapers, mutateReloadScrapers,
queryScrapeSceneQueryFragment, queryScrapeSceneQueryFragment,
mutateCreateScene,
} from "src/core/StashService"; } from "src/core/StashService";
import { import {
PerformerSelect, PerformerSelect,
@ -34,7 +35,8 @@ import useToast from "src/hooks/Toast";
import { ImageUtils, FormUtils, getStashIDs } from "src/utils"; import { ImageUtils, FormUtils, getStashIDs } from "src/utils";
import { MovieSelect } from "src/components/Shared/Select"; import { MovieSelect } from "src/components/Shared/Select";
import { useFormik } from "formik"; 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 { ConfigurationContext } from "src/hooks/Config";
import { stashboxDisplayName } from "src/utils/stashbox"; import { stashboxDisplayName } from "src/utils/stashbox";
import { SceneMovieTable } from "./SceneMovieTable"; import { SceneMovieTable } from "./SceneMovieTable";
@ -50,19 +52,28 @@ const SceneScrapeDialog = lazy(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazy(() => import("./SceneQueryModal")); const SceneQueryModal = lazy(() => import("./SceneQueryModal"));
interface IProps { interface IProps {
scene: GQL.SceneDataFragment; scene: Partial<GQL.SceneDataFragment>;
initialCoverImage?: string;
isNew?: boolean;
isVisible: boolean; isVisible: boolean;
onDelete: () => void; onDelete?: () => void;
onUpdate?: () => void;
} }
export const SceneEditPanel: React.FC<IProps> = ({ export const SceneEditPanel: React.FC<IProps> = ({
scene, scene,
initialCoverImage,
isNew = false,
isVisible, isVisible,
onDelete, onDelete,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const Toast = useToast(); 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 }[]>( const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
[] []
); );
@ -84,15 +95,17 @@ export const SceneEditPanel: React.FC<IProps> = ({
>(); >();
useEffect(() => { useEffect(() => {
setCoverImagePreview(scene.paths.screenshot ?? undefined); setCoverImagePreview(
}, [scene.paths.screenshot]); initialCoverImage ?? scene.paths?.screenshot ?? undefined
);
}, [scene.paths?.screenshot, initialCoverImage]);
useEffect(() => { useEffect(() => {
setGalleries( setGalleries(
scene.galleries.map((g) => ({ scene.galleries?.map((g) => ({
id: g.id, id: g.id,
title: objectTitle(g), title: objectTitle(g),
})) })) ?? []
); );
}, [scene.galleries]); }, [scene.galleries]);
@ -142,10 +155,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
return { movie_id: m.movie.id, scene_index: m.scene_index }; return { movie_id: m.movie.id, scene_index: m.scene_index };
}), }),
tag_ids: (scene.tags ?? []).map((t) => t.id), tag_ids: (scene.tags ?? []).map((t) => t.id),
cover_image: undefined, cover_image: initialCoverImage,
stash_ids: getStashIDs(scene.stash_ids), stash_ids: getStashIDs(scene.stash_ids),
}), }),
[scene] [scene, initialCoverImage]
); );
type InputValues = typeof initialValues; type InputValues = typeof initialValues;
@ -154,7 +167,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
initialValues, initialValues,
enableReinitialize: true, enableReinitialize: true,
validationSchema: schema, validationSchema: schema,
onSubmit: (values) => onSave(getSceneInput(values)), onSubmit: (values) => onSave(values),
}); });
function setRating(v: number) { function setRating(v: number) {
@ -180,7 +193,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
formik.handleSubmit(); formik.handleSubmit();
}); });
Mousetrap.bind("d d", () => { Mousetrap.bind("d d", () => {
onDelete(); if (onDelete) {
onDelete();
}
}); });
// numeric keypresses get caught by jwplayer, so blur the element // numeric keypresses get caught by jwplayer, so blur the element
@ -234,7 +249,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
function getSceneInput(input: InputValues): GQL.SceneUpdateInput { function getSceneInput(input: InputValues): GQL.SceneUpdateInput {
return { return {
id: scene.id, id: scene.id!,
...input, ...input,
}; };
} }
@ -256,27 +271,49 @@ export const SceneEditPanel: React.FC<IProps> = ({
formik.setFieldValue("movies", newMovies); 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); setIsLoading(true);
try { try {
const result = await updateScene({ if (!isNew) {
variables: { const updateValues = getSceneInput(input);
input: { const result = await updateScene({
...input, variables: {
rating: input.rating ?? null, 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 if (result.data?.sceneUpdate) {
formik.resetForm({ values: formik.values }); 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) { } catch (e) {
Toast.error(e); Toast.error(e);
} }
@ -316,7 +353,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
async function onScrapeClicked(s: GQL.ScraperSourceInput) { async function onScrapeClicked(s: GQL.ScraperSourceInput) {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await queryScrapeScene(s, scene.id); const result = await queryScrapeScene(s, scene.id!);
if (!result.data || !result.data.scrapeSingleScene?.length) { if (!result.data || !result.data.scrapeSingleScene?.length) {
Toast.success({ Toast.success({
content: "No scenes found", content: "No scenes found",
@ -399,7 +436,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
const currentScene = getSceneInput(formik.values); const currentScene = getSceneInput(formik.values);
if (!currentScene.cover_image) { if (!currentScene.cover_image) {
currentScene.cover_image = scene.paths.screenshot; currentScene.cover_image = scene.paths!.screenshot;
} }
return ( return (
@ -670,6 +707,24 @@ export const SceneEditPanel: React.FC<IProps> = ({
); );
} }
const image = useMemo(() => {
if (imageEncoding) {
return <LoadingIndicator message="Encoding image..." />;
}
if (coverImagePreview) {
return (
<img
className="scene-cover"
src={coverImagePreview}
alt={intl.formatMessage({ id: "cover_image" })}
/>
);
}
return <div></div>;
}, [imageEncoding, coverImagePreview, intl]);
if (isLoading) return <LoadingIndicator />; if (isLoading) return <LoadingIndicator />;
return ( return (
@ -687,25 +742,29 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Button <Button
className="edit-button" className="edit-button"
variant="primary" variant="primary"
disabled={!formik.dirty} disabled={!isNew && !formik.dirty}
onClick={() => formik.submitForm()} onClick={() => formik.submitForm()}
> >
<FormattedMessage id="actions.save" /> <FormattedMessage id="actions.save" />
</Button> </Button>
<Button {onDelete && (
className="edit-button" <Button
variant="danger" className="edit-button"
onClick={() => onDelete()} variant="danger"
> onClick={() => onDelete()}
<FormattedMessage id="actions.delete" /> >
</Button> <FormattedMessage id="actions.delete" />
</div> </Button>
<div className="ml-auto pr-3 text-right d-flex"> )}
<ButtonGroup className="scraper-group">
{renderScraperMenu()}
{renderScrapeQueryMenu()}
</ButtonGroup>
</div> </div>
{!isNew && (
<div className="ml-auto pr-3 text-right d-flex">
<ButtonGroup className="scraper-group">
{renderScraperMenu()}
{renderScrapeQueryMenu()}
</ButtonGroup>
</div>
)}
</div> </div>
<div className="form-container row px-3"> <div className="form-container row px-3">
<div className="col-12 col-lg-7 col-xl-12"> <div className="col-12 col-lg-7 col-xl-12">
@ -758,8 +817,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
})} })}
<Col sm={9}> <Col sm={9}>
<GallerySelect <GallerySelect
galleries={galleries} selected={galleries}
onSelect={(items) => onSetGalleries(items)} onSelect={(items) => onSetGalleries(items)}
isMulti
/> />
</Col> </Col>
</Form.Group> </Form.Group>
@ -918,15 +978,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
<Form.Label> <Form.Label>
<FormattedMessage id="cover_image" /> <FormattedMessage id="cover_image" />
</Form.Label> </Form.Label>
{imageEncoding ? ( {image}
<LoadingIndicator message="Encoding image..." />
) : (
<img
className="scene-cover"
src={coverImagePreview}
alt={intl.formatMessage({ id: "cover_image" })}
/>
)}
<ImageInput <ImageInput
isEditing isEditing
onImageChange={onCoverImageChange} onImageChange={onCoverImageChange}

View file

@ -1,8 +1,10 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { Accordion, Button, Card } from "react-bootstrap"; import { Accordion, Button, Card } from "react-bootstrap";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { useHistory } from "react-router-dom";
import { TruncatedText } from "src/components/Shared"; import { TruncatedText } from "src/components/Shared";
import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog"; import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog";
import ReassignFilesDialog from "src/components/Shared/ReassignFilesDialog";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { mutateSceneSetPrimaryFile } from "src/core/StashService"; import { mutateSceneSetPrimaryFile } from "src/core/StashService";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@ -10,11 +12,13 @@ import { NavUtils, TextUtils, getStashboxBase } from "src/utils";
import { TextField, URLField } from "src/utils/field"; import { TextField, URLField } from "src/utils/field";
interface IFileInfoPanelProps { interface IFileInfoPanelProps {
sceneID: string;
file: GQL.VideoFileDataFragment; file: GQL.VideoFileDataFragment;
primary?: boolean; primary?: boolean;
ofMany?: boolean; ofMany?: boolean;
onSetPrimaryFile?: () => void; onSetPrimaryFile?: () => void;
onDeleteFile?: () => void; onDeleteFile?: () => void;
onReassign?: () => void;
loading?: boolean; loading?: boolean;
} }
@ -22,6 +26,7 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
props: IFileInfoPanelProps props: IFileInfoPanelProps
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory();
function renderFileSize() { function renderFileSize() {
const { size, unit } = TextUtils.fileSize(props.file.size); const { size, unit } = TextUtils.fileSize(props.file.size);
@ -47,6 +52,12 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
const phash = props.file.fingerprints.find((f) => f.type === "phash"); const phash = props.file.fingerprints.find((f) => f.type === "phash");
const checksum = props.file.fingerprints.find((f) => f.type === "md5"); 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 ( return (
<div> <div>
<dl className="container scene-file-info details-list"> <dl className="container scene-file-info details-list">
@ -122,6 +133,16 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
> >
<FormattedMessage id="actions.make_primary" /> <FormattedMessage id="actions.make_primary" />
</Button> </Button>
<Button
className="edit-button"
disabled={props.loading}
onClick={props.onReassign}
>
<FormattedMessage id="actions.reassign" />
</Button>
<Button className="edit-button" onClick={onSplit}>
<FormattedMessage id="actions.split" />
</Button>
<Button <Button
variant="danger" variant="danger"
disabled={props.loading} disabled={props.loading}
@ -148,6 +169,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
const [deletingFile, setDeletingFile] = useState< const [deletingFile, setDeletingFile] = useState<
GQL.VideoFileDataFragment | undefined GQL.VideoFileDataFragment | undefined
>(); >();
const [reassigningFile, setReassigningFile] = useState<
GQL.VideoFileDataFragment | undefined
>();
function renderStashIDs() { function renderStashIDs() {
if (!props.scene.stash_ids.length) { if (!props.scene.stash_ids.length) {
@ -213,7 +237,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
} }
if (props.scene.files.length === 1) { if (props.scene.files.length === 1) {
return <FileInfoPanel file={props.scene.files[0]} />; return (
<FileInfoPanel sceneID={props.scene.id} file={props.scene.files[0]} />
);
} }
async function onSetPrimaryFile(fileID: string) { async function onSetPrimaryFile(fileID: string) {
@ -235,6 +261,12 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
selected={[deletingFile]} selected={[deletingFile]}
/> />
)} )}
{reassigningFile && (
<ReassignFilesDialog
onClose={() => setReassigningFile(undefined)}
selected={reassigningFile}
/>
)}
{props.scene.files.map((file, index) => ( {props.scene.files.map((file, index) => (
<Card key={file.id} className="scene-file-card"> <Card key={file.id} className="scene-file-card">
<Accordion.Toggle as={Card.Header} eventKey={file.id}> <Accordion.Toggle as={Card.Header} eventKey={file.id}>
@ -243,11 +275,13 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
<Accordion.Collapse eventKey={file.id}> <Accordion.Collapse eventKey={file.id}>
<Card.Body> <Card.Body>
<FileInfoPanel <FileInfoPanel
sceneID={props.scene.id}
file={file} file={file}
primary={index === 0} primary={index === 0}
ofMany ofMany
onSetPrimaryFile={() => onSetPrimaryFile(file.id)} onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
onDeleteFile={() => setDeletingFile(file)} onDeleteFile={() => setDeletingFile(file)}
onReassign={() => setReassigningFile(file)}
loading={loading} loading={loading}
/> />
</Card.Body> </Card.Body>
@ -256,7 +290,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
))} ))}
</Accordion> </Accordion>
); );
}, [props.scene, loading, Toast, deletingFile]); }, [props.scene, loading, Toast, deletingFile, reassigningFile]);
return ( return (
<> <>

View file

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { StudioSelect, PerformerSelect } from "src/components/Shared"; import { StudioSelect, PerformerSelect } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { MovieSelect, TagSelect } from "src/components/Shared/Select"; import { MovieSelect, TagSelect } from "src/components/Shared/Select";
@ -9,6 +9,7 @@ import {
ScrapedInputGroupRow, ScrapedInputGroupRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
ScrapedImageRow, ScrapedImageRow,
IHasName,
} from "src/components/Shared/ScrapeDialog"; } from "src/components/Shared/ScrapeDialog";
import clone from "lodash-es/clone"; import clone from "lodash-es/clone";
import { import {
@ -22,35 +23,45 @@ import useToast from "src/hooks/Toast";
import DurationUtils from "src/utils/duration"; import DurationUtils from "src/utils/duration";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
function renderScrapedStudio( interface IScrapedStudioRow {
result: ScrapeResult<string>, title: string;
isNew?: boolean, result: ScrapeResult<string>;
onChange?: (value: string) => void onChange: (value: ScrapeResult<string>) => void;
) { newStudio?: GQL.ScrapedStudio;
const resultValue = isNew ? result.newValue : result.originalValue; onCreateNew?: (value: GQL.ScrapedStudio) => void;
const value = resultValue ? [resultValue] : [];
return (
<StudioSelect
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items[0]?.id);
}
}}
ids={value}
/>
);
} }
function renderScrapedStudioRow( export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
title: string, title,
result: ScrapeResult<string>, result,
onChange: (value: ScrapeResult<string>) => void, onChange,
newStudio?: GQL.ScrapedStudio, newStudio,
onCreateNew?: (value: GQL.ScrapedStudio) => void onCreateNew,
) { }) => {
function renderScrapedStudio(
scrapeResult: ScrapeResult<string>,
isNew?: boolean,
onChangeFn?: (value: string) => void
) {
const resultValue = isNew
? scrapeResult.newValue
: scrapeResult.originalValue;
const value = resultValue ? [resultValue] : [];
return (
<StudioSelect
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChangeFn) {
onChangeFn(items[0]?.id);
}
}}
ids={value}
/>
);
}
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title={title} title={title}
@ -68,164 +79,199 @@ function renderScrapedStudioRow(
}} }}
/> />
); );
};
interface IScrapedObjectsRow<T> {
title: string;
result: ScrapeResult<string[]>;
onChange: (value: ScrapeResult<string[]>) => void;
newObjects?: T[];
onCreateNew?: (value: T) => void;
renderObjects: (
result: ScrapeResult<string[]>,
isNew?: boolean,
onChange?: (value: string[]) => void
) => JSX.Element;
} }
function renderScrapedPerformers( export const ScrapedObjectsRow = <T extends IHasName>(
result: ScrapeResult<string[]>, props: IScrapedObjectsRow<T>
isNew?: boolean, ) => {
onChange?: (value: string[]) => void const {
) { title,
const resultValue = isNew ? result.newValue : result.originalValue; result,
const value = resultValue ?? []; onChange,
newObjects,
return ( onCreateNew,
<PerformerSelect renderObjects,
isMulti } = props;
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
function renderScrapedPerformersRow(
title: string,
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void,
newPerformers: GQL.ScrapedPerformer[],
onCreateNew?: (value: GQL.ScrapedPerformer) => void
) {
const performersCopy = newPerformers.map((p) => {
const name: string = p.name ?? "";
return { ...p, name };
});
return ( return (
<ScrapeDialogRow <ScrapeDialogRow
title={title} title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedPerformers(result)} renderOriginalField={() => renderObjects(result)}
renderNewField={() => renderNewField={() =>
renderScrapedPerformers(result, true, (value) => renderObjects(result, true, (value) =>
onChange(result.cloneWithValue(value)) onChange(result.cloneWithValue(value))
) )
} }
onChange={onChange} onChange={onChange}
newValues={performersCopy} newValues={newObjects}
onCreateNew={(i) => { onCreateNew={(i) => {
if (onCreateNew) onCreateNew(newPerformers[i]); if (onCreateNew) onCreateNew(newObjects![i]);
}} }}
/> />
); );
} };
function renderScrapedMovies( type IScrapedObjectRowImpl<T> = Omit<IScrapedObjectsRow<T>, "renderObjects">;
result: ScrapeResult<string[]>,
isNew?: boolean, export const ScrapedPerformersRow: React.FC<
onChange?: (value: string[]) => void IScrapedObjectRowImpl<GQL.ScrapedPerformer>
) { > = ({ title, result, onChange, newObjects, onCreateNew }) => {
const resultValue = isNew ? result.newValue : result.originalValue; const performersCopy = useMemo(() => {
const value = resultValue ?? []; return (
newObjects?.map((p) => {
const name: string = p.name ?? "";
return { ...p, name };
}) ?? []
);
}, [newObjects]);
type PerformerType = GQL.ScrapedPerformer & {
name: string;
};
function renderScrapedPerformers(
scrapeResult: ScrapeResult<string[]>,
isNew?: boolean,
onChangeFn?: (value: string[]) => void
) {
const resultValue = isNew
? scrapeResult.newValue
: scrapeResult.originalValue;
const value = resultValue ?? [];
return (
<PerformerSelect
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChangeFn) {
onChangeFn(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
return ( return (
<MovieSelect <ScrapedObjectsRow<PerformerType>
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
function renderScrapedMoviesRow(
title: string,
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void,
newMovies: GQL.ScrapedMovie[],
onCreateNew?: (value: GQL.ScrapedMovie) => void
) {
const moviesCopy = newMovies.map((p) => {
const name: string = p.name ?? "";
return { ...p, name };
});
return (
<ScrapeDialogRow
title={title} title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedMovies(result)} renderObjects={renderScrapedPerformers}
renderNewField={() =>
renderScrapedMovies(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
onChange={onChange} onChange={onChange}
newValues={moviesCopy} newObjects={performersCopy}
onCreateNew={(i) => { onCreateNew={onCreateNew}
if (onCreateNew) onCreateNew(newMovies[i]);
}}
/> />
); );
} };
function renderScrapedTags( export const ScrapedMoviesRow: React.FC<
result: ScrapeResult<string[]>, IScrapedObjectRowImpl<GQL.ScrapedMovie>
isNew?: boolean, > = ({ title, result, onChange, newObjects, onCreateNew }) => {
onChange?: (value: string[]) => void const moviesCopy = useMemo(() => {
) { return (
const resultValue = isNew ? result.newValue : result.originalValue; newObjects?.map((p) => {
const value = resultValue ?? []; const name: string = p.name ?? "";
return { ...p, name };
}) ?? []
);
}, [newObjects]);
type MovieType = GQL.ScrapedMovie & {
name: string;
};
function renderScrapedMovies(
scrapeResult: ScrapeResult<string[]>,
isNew?: boolean,
onChangeFn?: (value: string[]) => void
) {
const resultValue = isNew
? scrapeResult.newValue
: scrapeResult.originalValue;
const value = resultValue ?? [];
return (
<MovieSelect
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChangeFn) {
onChangeFn(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
return ( return (
<TagSelect <ScrapedObjectsRow<MovieType>
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
function renderScrapedTagsRow(
title: string,
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void,
newTags: GQL.ScrapedTag[],
onCreateNew?: (value: GQL.ScrapedTag) => void
) {
return (
<ScrapeDialogRow
title={title} title={title}
result={result} result={result}
renderOriginalField={() => renderScrapedTags(result)} renderObjects={renderScrapedMovies}
renderNewField={() =>
renderScrapedTags(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
newValues={newTags}
onChange={onChange} onChange={onChange}
onCreateNew={(i) => { newObjects={moviesCopy}
if (onCreateNew) onCreateNew(newTags[i]); onCreateNew={onCreateNew}
}}
/> />
); );
} };
export const ScrapedTagsRow: React.FC<
IScrapedObjectRowImpl<GQL.ScrapedTag>
> = ({ title, result, onChange, newObjects, onCreateNew }) => {
function renderScrapedTags(
scrapeResult: ScrapeResult<string[]>,
isNew?: boolean,
onChangeFn?: (value: string[]) => void
) {
const resultValue = isNew
? scrapeResult.newValue
: scrapeResult.originalValue;
const value = resultValue ?? [];
return (
<TagSelect
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChangeFn) {
onChangeFn(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
return (
<ScrapedObjectsRow<GQL.ScrapedTag>
title={title}
result={result}
renderObjects={renderScrapedTags}
onChange={onChange}
newObjects={newObjects}
onCreateNew={onCreateNew}
/>
);
};
interface ISceneScrapeDialogProps { interface ISceneScrapeDialogProps {
scene: Partial<GQL.SceneUpdateInput>; scene: Partial<GQL.SceneUpdateInput>;
@ -593,34 +639,34 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
result={director} result={director}
onChange={(value) => setDirector(value)} onChange={(value) => setDirector(value)}
/> />
{renderScrapedStudioRow( <ScrapedStudioRow
intl.formatMessage({ id: "studios" }), title={intl.formatMessage({ id: "studios" })}
studio, result={studio}
(value) => setStudio(value), onChange={(value) => setStudio(value)}
newStudio, newStudio={newStudio}
createNewStudio onCreateNew={createNewStudio}
)} />
{renderScrapedPerformersRow( <ScrapedPerformersRow
intl.formatMessage({ id: "performers" }), title={intl.formatMessage({ id: "performers" })}
performers, result={performers}
(value) => setPerformers(value), onChange={(value) => setPerformers(value)}
newPerformers, newObjects={newPerformers}
createNewPerformer onCreateNew={createNewPerformer}
)} />
{renderScrapedMoviesRow( <ScrapedMoviesRow
intl.formatMessage({ id: "movies" }), title={intl.formatMessage({ id: "movies" })}
movies, result={movies}
(value) => setMovies(value), onChange={(value) => setMovies(value)}
newMovies, newObjects={newMovies}
createNewMovie onCreateNew={createNewMovie}
)} />
{renderScrapedTagsRow( <ScrapedTagsRow
intl.formatMessage({ id: "tags" }), title={intl.formatMessage({ id: "tags" })}
tags, result={tags}
(value) => setTags(value), onChange={(value) => setTags(value)}
newTags, newObjects={newTags}
createNewTag onCreateNew={createNewTag}
)} />
<ScrapedTextAreaRow <ScrapedTextAreaRow
title={intl.formatMessage({ id: "details" })} title={intl.formatMessage({ id: "details" })}
result={details} result={details}

View file

@ -25,6 +25,8 @@ import { TaggerContext } from "../Tagger/context";
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog"; import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
import { faPlay } from "@fortawesome/free-solid-svg-icons"; import { faPlay } from "@fortawesome/free-solid-svg-icons";
import { SceneMergeModal } from "./SceneMergeDialog";
import { objectTitle } from "src/core/files";
interface ISceneList { interface ISceneList {
filterHook?: (filter: ListFilterModel) => ListFilterModel; filterHook?: (filter: ListFilterModel) => ListFilterModel;
@ -41,6 +43,9 @@ export const SceneList: React.FC<ISceneList> = ({
const history = useHistory(); const history = useHistory();
const config = React.useContext(ConfigurationContext); const config = React.useContext(ConfigurationContext);
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
const [mergeScenes, setMergeScenes] = useState<
{ id: string; title: string }[] | undefined
>(undefined);
const [isIdentifyDialogOpen, setIsIdentifyDialogOpen] = useState(false); const [isIdentifyDialogOpen, setIsIdentifyDialogOpen] = useState(false);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false); const [isExportAll, setIsExportAll] = useState(false);
@ -66,6 +71,11 @@ export const SceneList: React.FC<ISceneList> = ({
onClick: identify, onClick: identify,
isDisplayed: showWhenSelected, isDisplayed: showWhenSelected,
}, },
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: merge,
isDisplayed: showWhenSelected,
},
{ {
text: intl.formatMessage({ id: "actions.export" }), text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport, onClick: onExport,
@ -166,6 +176,24 @@ export const SceneList: React.FC<ISceneList> = ({
setIsIdentifyDialogOpen(true); setIsIdentifyDialogOpen(true);
} }
async function merge(
result: FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
const selected =
result.data?.findScenes.scenes
.filter((s) => selectedIds.has(s.id))
.map((s) => {
return {
id: s.id,
title: objectTitle(s),
};
}) ?? [];
setMergeScenes(selected);
}
async function onExport() { async function onExport() {
setIsExportAll(false); setIsExportAll(false);
setIsExportDialogOpen(true); setIsExportDialogOpen(true);
@ -237,6 +265,23 @@ export const SceneList: React.FC<ISceneList> = ({
); );
} }
function renderMergeDialog() {
if (mergeScenes) {
return (
<SceneMergeModal
scenes={mergeScenes}
onClose={(mergedID?: string) => {
setMergeScenes(undefined);
if (mergedID) {
history.push(`/scenes/${mergedID}`);
}
}}
show
/>
);
}
}
function renderScenes( function renderScenes(
result: FindScenesQueryResult, result: FindScenesQueryResult,
filter: ListFilterModel, filter: ListFilterModel,
@ -293,6 +338,7 @@ export const SceneList: React.FC<ISceneList> = ({
{maybeRenderSceneGenerateDialog(selectedIds)} {maybeRenderSceneGenerateDialog(selectedIds)}
{maybeRenderSceneIdentifyDialog(selectedIds)} {maybeRenderSceneIdentifyDialog(selectedIds)}
{maybeRenderSceneExportDialog(selectedIds)} {maybeRenderSceneExportDialog(selectedIds)}
{renderMergeDialog()}
{renderScenes(result, filter, selectedIds)} {renderScenes(result, filter, selectedIds)}
</> </>
); );

View file

@ -0,0 +1,662 @@
import { Form, Col, Row, Button, FormControl } from "react-bootstrap";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import {
GallerySelect,
Icon,
LoadingIndicator,
Modal,
SceneSelect,
StringListSelect,
} from "src/components/Shared";
import { FormUtils, ImageUtils } from "src/utils";
import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService";
import { FormattedMessage, useIntl } from "react-intl";
import { useToast } from "src/hooks";
import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons";
import {
hasScrapedValues,
ScrapeDialog,
ScrapeDialogRow,
ScrapedImageRow,
ScrapedInputGroupRow,
ScrapedTextAreaRow,
ScrapeResult,
} from "../Shared/ScrapeDialog";
import { clone, uniq } from "lodash-es";
import {
ScrapedMoviesRow,
ScrapedPerformersRow,
ScrapedStudioRow,
ScrapedTagsRow,
} from "./SceneDetails/SceneScrapeDialog";
import { galleryTitle } from "src/core/galleries";
import { RatingStars } from "./SceneDetails/RatingStars";
interface IStashIDsField {
values: GQL.StashId[];
}
const StashIDsField: React.FC<IStashIDsField> = ({ values }) => {
return <StringListSelect value={values.map((v) => v.stash_id)} />;
};
interface ISceneMergeDetailsProps {
sources: GQL.SlimSceneDataFragment[];
dest: GQL.SlimSceneDataFragment;
onClose: (values?: GQL.SceneUpdateInput) => void;
}
const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
sources,
dest,
onClose,
}) => {
const intl = useIntl();
const [loading, setLoading] = useState(true);
const [title, setTitle] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.title)
);
const [url, setURL] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.url)
);
const [date, setDate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.date)
);
const [rating, setRating] = useState(new ScrapeResult<number>(dest.rating));
const [oCounter, setOCounter] = useState(
new ScrapeResult<number>(dest.o_counter)
);
const [studio, setStudio] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.studio?.id)
);
function sortIdList(idList?: string[] | null) {
if (!idList) {
return;
}
const ret = clone(idList);
// sort by id numerically
ret.sort((a, b) => {
return parseInt(a, 10) - parseInt(b, 10);
});
return ret;
}
const [performers, setPerformers] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(sortIdList(dest.performers.map((p) => p.id)))
);
const [movies, setMovies] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(sortIdList(dest.movies.map((p) => p.movie.id)))
);
const [tags, setTags] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(sortIdList(dest.tags.map((t) => t.id)))
);
const [details, setDetails] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.details)
);
const [galleries, setGalleries] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(sortIdList(dest.galleries.map((p) => p.id)))
);
const [stashIDs, setStashIDs] = useState(new ScrapeResult<GQL.StashId[]>([]));
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(dest.paths.screenshot)
);
// calculate the values for everything
// uses the first set value for single value fields, and combines all
useEffect(() => {
async function loadImages() {
const src = sources.find((s) => s.paths.screenshot);
if (!dest.paths.screenshot || !src) return;
setLoading(true);
const destData = await ImageUtils.imageToDataURL(dest.paths.screenshot);
const srcData = await ImageUtils.imageToDataURL(src.paths!.screenshot!);
// keep destination image by default
const useNewValue = false;
setImage(new ScrapeResult(destData, srcData, useNewValue));
setLoading(false);
}
// append dest to all so that if dest has stash_ids with the same
// endpoint, then it will be excluded first
const all = sources.concat(dest);
setTitle(
new ScrapeResult(
dest.title,
sources.find((s) => s.title)?.title,
!dest.title
)
);
setURL(
new ScrapeResult(dest.url, sources.find((s) => s.url)?.url, !dest.url)
);
setDate(
new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date)
);
setStudio(
new ScrapeResult(
dest.studio?.id,
sources.find((s) => s.studio)?.studio?.id,
!dest.studio
)
);
setPerformers(
new ScrapeResult(
dest.performers.map((p) => p.id),
uniq(all.map((s) => s.performers.map((p) => p.id)).flat())
)
);
setTags(
new ScrapeResult(
dest.tags.map((p) => p.id),
uniq(all.map((s) => s.tags.map((p) => p.id)).flat())
)
);
setDetails(
new ScrapeResult(
dest.details,
sources.find((s) => s.details)?.details,
!dest.details
)
);
setMovies(
new ScrapeResult(
dest.movies.map((m) => m.movie.id),
uniq(all.map((s) => s.movies.map((m) => m.movie.id)).flat())
)
);
setGalleries(
new ScrapeResult(
dest.galleries.map((p) => p.id),
uniq(all.map((s) => s.galleries.map((p) => p.id)).flat())
)
);
setRating(
new ScrapeResult(
dest.rating,
sources.find((s) => s.rating)?.rating,
!dest.rating
)
);
setOCounter(
new ScrapeResult(
dest.o_counter ?? 0,
all.map((s) => s.o_counter ?? 0).reduce((pv, cv) => pv + cv, 0)
)
);
setStashIDs(
new ScrapeResult(
dest.stash_ids,
all
.map((s) => s.stash_ids)
.flat()
.filter((s, index, a) => {
// remove entries with duplicate endpoints
return index === a.findIndex((ss) => ss.endpoint === s.endpoint);
}),
!dest.stash_ids.length
)
);
loadImages();
}, [sources, dest]);
const convertGalleries = useCallback(
(ids?: string[]) => {
const all = [dest, ...sources];
return ids
?.map((g) =>
all
.map((s) => s.galleries)
.flat()
.find((gg) => g === gg.id)
)
.map((g) => {
return {
id: g!.id,
title: galleryTitle(g!),
};
});
},
[dest, sources]
);
const originalGalleries = useMemo(() => {
return convertGalleries(galleries.originalValue);
}, [galleries, convertGalleries]);
const newGalleries = useMemo(() => {
return convertGalleries(galleries.newValue);
}, [galleries, convertGalleries]);
// ensure this is updated if fields are changed
const hasValues = useMemo(() => {
return hasScrapedValues([
title,
url,
date,
rating,
oCounter,
galleries,
studio,
performers,
movies,
tags,
details,
stashIDs,
image,
]);
}, [
title,
url,
date,
rating,
oCounter,
galleries,
studio,
performers,
movies,
tags,
details,
stashIDs,
image,
]);
function renderScrapeRows() {
if (loading) {
return (
<div>
<LoadingIndicator />
</div>
);
}
if (!hasValues) {
return (
<div>
<FormattedMessage id="dialogs.merge.empty_results" />
</div>
);
}
return (
<>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "title" })}
result={title}
onChange={(value) => setTitle(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "url" })}
result={url}
onChange={(value) => setURL(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "date" })}
placeholder="YYYY-MM-DD"
result={date}
onChange={(value) => setDate(value)}
/>
<ScrapeDialogRow
title={intl.formatMessage({ id: "rating" })}
result={rating}
renderOriginalField={() => (
<RatingStars value={rating.originalValue} disabled />
)}
renderNewField={() => (
<RatingStars value={rating.newValue} disabled />
)}
onChange={(value) => setRating(value)}
/>
<ScrapeDialogRow
title={intl.formatMessage({ id: "o_counter" })}
result={oCounter}
renderOriginalField={() => (
<FormControl
value={oCounter.originalValue ?? 0}
readOnly
onChange={() => {}}
className="bg-secondary text-white border-secondary"
/>
)}
renderNewField={() => (
<FormControl
value={oCounter.newValue ?? 0}
readOnly
onChange={() => {}}
className="bg-secondary text-white border-secondary"
/>
)}
onChange={(value) => setRating(value)}
/>
<ScrapeDialogRow
title={intl.formatMessage({ id: "galleries" })}
result={galleries}
renderOriginalField={() => (
<GallerySelect
className="form-control react-select"
selected={originalGalleries ?? []}
onSelect={() => {}}
disabled
/>
)}
renderNewField={() => (
<GallerySelect
className="form-control react-select"
selected={newGalleries ?? []}
onSelect={() => {}}
disabled
/>
)}
onChange={(value) => setGalleries(value)}
/>
<ScrapedStudioRow
title={intl.formatMessage({ id: "studios" })}
result={studio}
onChange={(value) => setStudio(value)}
/>
<ScrapedPerformersRow
title={intl.formatMessage({ id: "performers" })}
result={performers}
onChange={(value) => setPerformers(value)}
/>
<ScrapedMoviesRow
title={intl.formatMessage({ id: "movies" })}
result={movies}
onChange={(value) => setMovies(value)}
/>
<ScrapedTagsRow
title={intl.formatMessage({ id: "tags" })}
result={tags}
onChange={(value) => setTags(value)}
/>
<ScrapedTextAreaRow
title={intl.formatMessage({ id: "details" })}
result={details}
onChange={(value) => setDetails(value)}
/>
<ScrapeDialogRow
title={intl.formatMessage({ id: "stash_id" })}
result={stashIDs}
renderOriginalField={() => (
<StashIDsField values={stashIDs?.originalValue ?? []} />
)}
renderNewField={() => (
<StashIDsField values={stashIDs?.newValue ?? []} />
)}
onChange={(value) => setStashIDs(value)}
/>
<ScrapedImageRow
title={intl.formatMessage({ id: "cover_image" })}
className="scene-cover"
result={image}
onChange={(value) => setImage(value)}
/>
</>
);
}
function createValues(): GQL.SceneUpdateInput {
const all = [dest, ...sources];
// only set the cover image if it's different from the existing cover image
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
return {
id: dest.id,
title: title.getNewValue(),
url: url.getNewValue(),
date: date.getNewValue(),
rating: rating.getNewValue(),
o_counter: oCounter.getNewValue(),
gallery_ids: galleries.getNewValue(),
studio_id: studio.getNewValue(),
performer_ids: performers.getNewValue(),
movies: movies.getNewValue()?.map((m) => {
// find the equivalent movie in the original scenes
const found = all
.map((s) => s.movies)
.flat()
.find((mm) => mm.movie.id === m);
return {
movie_id: m,
scene_index: found!.scene_index,
};
}),
tag_ids: tags.getNewValue(),
details: details.getNewValue(),
stash_ids: stashIDs.getNewValue(),
cover_image: coverImage,
};
}
const dialogTitle = intl.formatMessage({
id: "actions.merge",
});
const destinationLabel = !hasValues
? ""
: intl.formatMessage({ id: "dialogs.merge.destination" });
const sourceLabel = !hasValues
? ""
: intl.formatMessage({ id: "dialogs.merge.source" });
return (
<ScrapeDialog
title={dialogTitle}
existingLabel={destinationLabel}
scrapedLabel={sourceLabel}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => {
if (!apply) {
onClose();
} else {
onClose(createValues());
}
}}
/>
);
};
interface ISceneMergeModalProps {
show: boolean;
onClose: (mergedID?: string) => void;
scenes: { id: string; title: string }[];
}
export const SceneMergeModal: React.FC<ISceneMergeModalProps> = ({
show,
onClose,
scenes,
}) => {
const [sourceScenes, setSourceScenes] = useState<
{ id: string; title: string }[]
>([]);
const [destScene, setDestScene] = useState<{ id: string; title: string }[]>(
[]
);
const [loadedSources, setLoadedSources] = useState<
GQL.SlimSceneDataFragment[]
>([]);
const [loadedDest, setLoadedDest] = useState<GQL.SlimSceneDataFragment>();
const [running, setRunning] = useState(false);
const [secondStep, setSecondStep] = useState(false);
const intl = useIntl();
const Toast = useToast();
const title = intl.formatMessage({
id: "actions.merge",
});
useEffect(() => {
if (scenes.length > 0) {
// set the first scene as the destination, others as source
setDestScene([scenes[0]]);
if (scenes.length > 1) {
setSourceScenes(scenes.slice(1));
}
}
}, [scenes]);
async function loadScenes() {
const sceneIDs = sourceScenes.map((s) => parseInt(s.id));
sceneIDs.push(parseInt(destScene[0].id));
const query = await queryFindScenesByID(sceneIDs);
const { scenes: loadedScenes } = query.data.findScenes;
setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id));
setLoadedSources(loadedScenes.filter((s) => s.id !== destScene[0].id));
setSecondStep(true);
}
async function onMerge(values: GQL.SceneUpdateInput) {
try {
setRunning(true);
const result = await mutateSceneMerge(
destScene[0].id,
sourceScenes.map((s) => s.id),
values
);
if (result.data?.sceneMerge) {
Toast.success({
content: intl.formatMessage({ id: "toast.merged_scenes" }),
});
// refetch the scene
await queryFindScenesByID([parseInt(destScene[0].id)]);
onClose(destScene[0].id);
}
onClose();
} catch (e) {
Toast.error(e);
} finally {
setRunning(false);
}
}
function canMerge() {
return sourceScenes.length > 0 && destScene.length !== 0;
}
function switchScenes() {
if (sourceScenes.length && destScene.length) {
const newDest = sourceScenes[0];
setSourceScenes([...sourceScenes.slice(1), destScene[0]]);
setDestScene([newDest]);
}
}
if (secondStep && destScene.length > 0) {
return (
<SceneMergeDetails
sources={loadedSources}
dest={loadedDest!}
onClose={(values) => {
if (values) {
onMerge(values);
} else {
onClose();
}
}}
/>
);
}
return (
<Modal
show={show}
header={title}
icon={faSignInAlt}
accept={{
text: intl.formatMessage({ id: "actions.next_action" }),
onClick: () => loadScenes(),
}}
disabled={!canMerge()}
cancel={{
variant: "secondary",
onClick: () => onClose(),
}}
isRunning={running}
>
<div className="form-container row px-3">
<div className="col-12 col-lg-6 col-xl-12">
<Form.Group controlId="source" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({ id: "dialogs.merge.source" }),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<SceneSelect
isMulti
onSelect={(items) => setSourceScenes(items)}
selected={sourceScenes}
/>
</Col>
</Form.Group>
<Form.Group
controlId="switch"
as={Row}
className="justify-content-center"
>
<Button
variant="secondary"
onClick={() => switchScenes()}
disabled={!sourceScenes.length || !destScene.length}
title={intl.formatMessage({ id: "actions.swap" })}
>
<Icon className="fa-fw" icon={faExchangeAlt} />
</Button>
</Form.Group>
<Form.Group controlId="destination" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({
id: "dialogs.merge.destination",
}),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<SceneSelect
onSelect={(items) => setDestScene(items)}
selected={destScene}
/>
</Col>
</Form.Group>
</div>
</div>
</Modal>
);
};

View file

@ -8,6 +8,7 @@ import { PersistanceLevel } from "src/hooks/ListHook";
const SceneList = lazy(() => import("./SceneList")); const SceneList = lazy(() => import("./SceneList"));
const SceneMarkerList = lazy(() => import("./SceneMarkerList")); const SceneMarkerList = lazy(() => import("./SceneMarkerList"));
const Scene = lazy(() => import("./SceneDetails/Scene")); const Scene = lazy(() => import("./SceneDetails/Scene"));
const SceneCreate = lazy(() => import("./SceneDetails/SceneCreate"));
const Scenes: React.FC = () => { const Scenes: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
@ -30,6 +31,7 @@ const Scenes: React.FC = () => {
)} )}
/> />
<Route exact path="/scenes/markers" component={SceneMarkerList} /> <Route exact path="/scenes/markers" component={SceneMarkerList} />
<Route exact path="/scenes/new" component={SceneCreate} />
<Route path="/scenes/:id" component={Scene} /> <Route path="/scenes/:id" component={Scene} />
</Switch> </Switch>
</> </>

View file

@ -240,6 +240,10 @@ textarea.scene-description {
.scene-card.card { .scene-card.card {
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
&.fileless {
background-color: darken($card-bg, 5%);
}
} }
.scene-cover { .scene-cover {
@ -476,7 +480,7 @@ input[type="range"].blue-slider {
} }
.rating-stars { .rating-stars {
display: inline-block; display: inline-flex;
button { button {
font-size: inherit; font-size: inherit;

View file

@ -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<IReassignFilesDialogProps> = (
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 (
<Modal
show
icon={faSignOutAlt}
header={header}
accept={{
onClick: onAccept,
text: intl.formatMessage({ id: "actions.reassign" }),
}}
cancel={{
onClick: () => props.onClose(),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
isRunning={reassigning}
>
<Form>
<Form.Group controlId="dest" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({
id: "dialogs.reassign_files.destination",
}),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<SceneSelect
selected={scenes}
onSelect={(items) => setScenes(items)}
/>
</Col>
</Form.Group>
</Form>
</Modal>
);
};
export default ReassignFilesDialog;

View file

@ -29,13 +29,17 @@ export class ScrapeResult<T> {
public scraped: boolean = false; public scraped: boolean = false;
public useNewValue: 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.originalValue = originalValue ?? undefined;
this.newValue = newValue ?? undefined; this.newValue = newValue ?? undefined;
const valuesEqual = isEqual(originalValue, newValue); const valuesEqual = isEqual(originalValue, newValue);
this.useNewValue = !!this.newValue && !valuesEqual; this.useNewValue = useNewValue ?? (!!this.newValue && !valuesEqual);
this.scraped = this.useNewValue; this.scraped = !!this.newValue && !valuesEqual;
} }
public setOriginalValue(value?: T) { public setOriginalValue(value?: T) {
@ -63,7 +67,12 @@ export class ScrapeResult<T> {
} }
} }
interface IHasName { // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function hasScrapedValues(values: ScrapeResult<any>[]) {
return values.some((r) => r.scraped);
}
export interface IHasName {
name: string | undefined; name: string | undefined;
} }
@ -347,6 +356,8 @@ export const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {
interface IScrapeDialogProps { interface IScrapeDialogProps {
title: string; title: string;
existingLabel?: string;
scrapedLabel?: string;
renderScrapeRows: () => JSX.Element; renderScrapeRows: () => JSX.Element;
onClose: (apply?: boolean) => void; onClose: (apply?: boolean) => void;
} }
@ -379,10 +390,14 @@ export const ScrapeDialog: React.FC<IScrapeDialogProps> = (
<Col lg={{ span: 9, offset: 3 }}> <Col lg={{ span: 9, offset: 3 }}>
<Row> <Row>
<Form.Label column xs="6"> <Form.Label column xs="6">
<FormattedMessage id="dialogs.scrape_results_existing" /> {props.existingLabel ?? (
<FormattedMessage id="dialogs.scrape_results_existing" />
)}
</Form.Label> </Form.Label>
<Form.Label column xs="6"> <Form.Label column xs="6">
<FormattedMessage id="dialogs.scrape_results_scraped" /> {props.scrapedLabel ?? (
<FormattedMessage id="dialogs.scrape_results_scraped" />
)}
</Form.Label> </Form.Label>
</Row> </Row>
</Col> </Col>

View file

@ -96,16 +96,13 @@ interface IFilterComponentProps extends IFilterProps {
interface IFilterSelectProps<T extends boolean> interface IFilterSelectProps<T extends boolean>
extends Omit<ISelectProps<T>, "onChange" | "items" | "onCreateOption"> {} extends Omit<ISelectProps<T>, "onChange" | "items" | "onCreateOption"> {}
type Gallery = { id: string; title: string }; type TitledObject = { id: string; title: string };
interface IGallerySelect { interface ITitledSelect {
galleries: Gallery[]; className?: string;
onSelect: (items: Gallery[]) => void; selected: TitledObject[];
} onSelect: (items: TitledObject[]) => void;
isMulti?: boolean;
type Scene = { id: string; title: string }; disabled?: boolean;
interface ISceneSelect {
scenes: Scene[];
onSelect: (items: Scene[]) => void;
} }
const getSelectedItems = (selectedItems: ValueType<Option, boolean>) => const getSelectedItems = (selectedItems: ValueType<Option, boolean>) =>
@ -318,7 +315,7 @@ const FilterSelectComponent = <T extends boolean>(
); );
}; };
export const GallerySelect: React.FC<IGallerySelect> = (props) => { export const GallerySelect: React.FC<ITitledSelect> = (props) => {
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const { data, loading } = GQL.useFindGalleriesQuery({ const { data, loading } = GQL.useFindGalleriesQuery({
skip: query === "", skip: query === "",
@ -349,13 +346,14 @@ export const GallerySelect: React.FC<IGallerySelect> = (props) => {
); );
}; };
const options = props.galleries.map((g) => ({ const options = props.selected.map((g) => ({
value: g.id, value: g.id,
label: g.title ?? "Unknown", label: g.title ?? "Unknown",
})); }));
return ( return (
<SelectComponent <SelectComponent
className={props.className}
onChange={onChange} onChange={onChange}
onInputChange={onInputChange} onInputChange={onInputChange}
isLoading={loading} isLoading={loading}
@ -369,7 +367,7 @@ export const GallerySelect: React.FC<IGallerySelect> = (props) => {
); );
}; };
export const SceneSelect: React.FC<ISceneSelect> = (props) => { export const SceneSelect: React.FC<ITitledSelect> = (props) => {
const [query, setQuery] = useState<string>(""); const [query, setQuery] = useState<string>("");
const { data, loading } = GQL.useFindScenesQuery({ const { data, loading } = GQL.useFindScenesQuery({
skip: query === "", skip: query === "",
@ -390,7 +388,7 @@ export const SceneSelect: React.FC<ISceneSelect> = (props) => {
setQuery(input); setQuery(input);
}, 500); }, 500);
const onChange = (selectedItems: ValueType<Option, true>) => { const onChange = (selectedItems: ValueType<Option, boolean>) => {
const selected = getSelectedItems(selectedItems); const selected = getSelectedItems(selectedItems);
props.onSelect( props.onSelect(
(selected ?? []).map((s) => ({ (selected ?? []).map((s) => ({
@ -400,7 +398,7 @@ export const SceneSelect: React.FC<ISceneSelect> = (props) => {
); );
}; };
const options = props.scenes.map((s) => ({ const options = props.selected.map((s) => ({
value: s.id, value: s.id,
label: s.title, label: s.title,
})); }));
@ -412,10 +410,63 @@ export const SceneSelect: React.FC<ISceneSelect> = (props) => {
isLoading={loading} isLoading={loading}
items={items} items={items}
selectedOptions={options} selectedOptions={options}
isMulti isMulti={props.isMulti ?? false}
placeholder="Search for scene..." placeholder="Search for scene..."
noOptionsMessage={query === "" ? null : "No scenes found."} noOptionsMessage={query === "" ? null : "No scenes found."}
showDropdown={false} showDropdown={false}
isDisabled={props.disabled}
/>
);
};
export const ImageSelect: React.FC<ITitledSelect> = (props) => {
const [query, setQuery] = useState<string>("");
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<Option, boolean>) => {
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 (
<SelectComponent
onChange={onChange}
onInputChange={onInputChange}
isLoading={loading}
items={items}
selectedOptions={options}
isMulti={props.isMulti ?? false}
placeholder="Search for image..."
noOptionsMessage={query === "" ? null : "No images found."}
showDropdown={false}
isDisabled={props.disabled}
/> />
); );
}; };
@ -804,3 +855,106 @@ export const FilterSelect: React.FC<IFilterProps & ITypeProps> = (props) =>
) : ( ) : (
<TagSelect {...props} creatable={false} /> <TagSelect {...props} creatable={false} />
); );
interface IStringListSelect {
options?: string[];
value: string[];
}
export const StringListSelect: React.FC<IStringListSelect> = ({
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<Styles<Option, true>> = {
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 (
<Select
classNamePrefix="react-select"
className="form-control react-select"
options={translatedOptions}
value={translatedValue}
isMulti
isDisabled
styles={styles}
components={{
IndicatorSeparator: () => null,
...{ DropdownIndicator: () => null },
...{ MultiValueRemove: () => null },
}}
/>
);
};
interface IListSelect<T> {
options?: T[];
value: T[];
toOptionType: (v: T) => { label: string; value: string };
fromOptionType?: (o: { label: string; value: string }) => T;
}
export const ListSelect = <T extends {}>(props: IListSelect<T>) => {
const { options = [], value, toOptionType } = props;
const translatedOptions = useMemo(() => {
return options.map(toOptionType);
}, [options, toOptionType]);
const translatedValue = useMemo(() => {
return value.map(toOptionType);
}, [value, toOptionType]);
const styles: Partial<Styles<{ label: string; value: string }, true>> = {
option: (base) => ({
...base,
color: "#000",
}),
container: (base, p) => ({
...base,
zIndex: p.selectProps.isFocused ? 10 : base.zIndex,
}),
multiValueRemove: (base, p) => ({
...base,
color: p.selectProps.isFocused ? base.color : "#333333",
}),
};
return (
<Select
classNamePrefix="react-select"
className="form-control react-select"
options={translatedOptions}
value={translatedValue}
isMulti
isDisabled
styles={styles}
components={{
IndicatorSeparator: () => null,
...{ DropdownIndicator: () => null },
...{ MultiValueRemove: () => null },
}}
/>
);
};

View file

@ -112,6 +112,14 @@ export function prepareQueryString(
} }
export const parsePath = (filePath: string) => { export const parsePath = (filePath: string) => {
if (!filePath) {
return {
paths: [],
file: "",
ext: "",
};
}
const path = filePath.toLowerCase(); const path = filePath.toLowerCase();
const isWin = /^([a-z]:|\\\\)/.test(path); const isWin = /^([a-z]:|\\\\)/.test(path);
const normalizedPath = isWin const normalizedPath = isWin

View file

@ -507,6 +507,63 @@ export const mutateSceneSetPrimaryFile = (id: string, fileID: string) =>
update: deleteCache(sceneMutationImpactedQueries), update: deleteCache(sceneMutationImpactedQueries),
}); });
export const mutateSceneAssignFile = (sceneID: string, fileID: string) =>
client.mutate<GQL.SceneAssignFileMutation>({
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<GQL.SceneMergeMutation>({
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<GQL.SceneCreateMutation>({
mutation: GQL.SceneCreateDocument,
variables: {
input,
},
update: deleteCache(sceneMutationImpactedQueries),
refetchQueries: getQueryNames([GQL.FindSceneDocument]),
});
const imageMutationImpactedQueries = [ const imageMutationImpactedQueries = [
GQL.FindPerformerDocument, GQL.FindPerformerDocument,
GQL.FindPerformersDocument, GQL.FindPerformersDocument,

View file

@ -1,4 +1,7 @@
### ✨ New Features ### ✨ 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 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 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)) * Added tag description filter criterion. ([#3011](https://github.com/stashapp/stash/pull/3011))

View file

@ -67,6 +67,7 @@
"play_selected": "Play selected", "play_selected": "Play selected",
"preview": "Preview", "preview": "Preview",
"previous_action": "Back", "previous_action": "Back",
"reassign": "Reassign",
"refresh": "Refresh", "refresh": "Refresh",
"reload_plugins": "Reload plugins", "reload_plugins": "Reload plugins",
"reload_scrapers": "Reload scrapers", "reload_scrapers": "Reload scrapers",
@ -98,11 +99,13 @@
"set_image": "Set image…", "set_image": "Set image…",
"show": "Show", "show": "Show",
"show_configuration": "Show Configuration", "show_configuration": "Show Configuration",
"split": "Split",
"skip": "Skip", "skip": "Skip",
"stop": "Stop", "stop": "Stop",
"submit": "Submit", "submit": "Submit",
"submit_stash_box": "Submit to Stash-Box", "submit_stash_box": "Submit to Stash-Box",
"submit_update": "Submit update", "submit_update": "Submit update",
"swap": "Swap",
"tasks": { "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.", "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.", "dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.",
@ -630,6 +633,7 @@
"developmentVersion": "Development Version", "developmentVersion": "Development Version",
"dialogs": { "dialogs": {
"aliases_must_be_unique": "aliases must be unique", "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_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
"delete_confirm": "Are you sure you want to delete {entityName}?", "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.}}", "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" "zoom": "Zoom"
} }
}, },
"merge": {
"destination": "Destination",
"empty_results": "Destination field values will be unchanged.",
"source": "Source"
},
"merge_tags": { "merge_tags": {
"destination": "Destination", "destination": "Destination",
"source": "Source" "source": "Source"
}, },
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?", "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": { "scene_gen": {
"force_transcodes": "Force Transcode generation", "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.", "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", "default_filter_set": "Default filter set",
"delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"generating_screenshot": "Generating screenshot…", "generating_screenshot": "Generating screenshot…",
"merged_scenes": "Merged scenes",
"merged_tags": "Merged tags", "merged_tags": "Merged tags",
"rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…",
"reassign_past_tense": "File reassigned",
"removed_entity": "Removed {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "removed_entity": "Removed {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
"saved_entity": "Saved {entity}", "saved_entity": "Saved {entity}",
"started_auto_tagging": "Started auto tagging", "started_auto_tagging": "Started auto tagging",

View file

@ -53,8 +53,22 @@ const usePasteImage = (
return false; return false;
}; };
const imageToDataURL = async (url: string) => {
const response = await fetch(url);
const blob = await response.blob();
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
const Image = { const Image = {
onImageChange, onImageChange,
usePasteImage, usePasteImage,
imageToDataURL,
}; };
export default Image; export default Image;