mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
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:
parent
d0b0be4dd4
commit
4a054ab081
60 changed files with 2550 additions and 412 deletions
|
|
@ -1,3 +1,11 @@
|
|||
mutation SceneCreate(
|
||||
$input: SceneCreateInput!) {
|
||||
|
||||
sceneCreate(input: $input) {
|
||||
...SceneData
|
||||
}
|
||||
}
|
||||
|
||||
mutation SceneUpdate(
|
||||
$input: SceneUpdateInput!) {
|
||||
|
||||
|
|
@ -43,3 +51,13 @@ mutation ScenesDestroy($ids: [ID!]!, $delete_file: Boolean, $delete_generated :
|
|||
mutation SceneGenerateScreenshot($id: ID!, $at: Float) {
|
||||
sceneGenerateScreenshot(id: $id, at: $at)
|
||||
}
|
||||
|
||||
mutation SceneAssignFile($input: AssignSceneFileInput!) {
|
||||
sceneAssignFile(input: $input)
|
||||
}
|
||||
|
||||
mutation SceneMerge($input: SceneMergeInput!) {
|
||||
sceneMerge(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
|
@ -162,7 +162,9 @@ type Mutation {
|
|||
setup(input: SetupInput!): Boolean!
|
||||
migrate(input: MigrateInput!): Boolean!
|
||||
|
||||
sceneCreate(input: SceneCreateInput!): Scene
|
||||
sceneUpdate(input: SceneUpdateInput!): Scene
|
||||
sceneMerge(input: SceneMergeInput!): Scene
|
||||
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
|
||||
sceneDestroy(input: SceneDestroyInput!): Boolean!
|
||||
scenesDestroy(input: ScenesDestroyInput!): Boolean!
|
||||
|
|
@ -182,6 +184,8 @@ type Mutation {
|
|||
sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker
|
||||
sceneMarkerDestroy(id: ID!): Boolean!
|
||||
|
||||
sceneAssignFile(input: AssignSceneFileInput!): Boolean!
|
||||
|
||||
imageUpdate(input: ImageUpdateInput!): Image
|
||||
bulkImageUpdate(input: BulkImageUpdateInput!): [Image!]
|
||||
imageDestroy(input: ImageDestroyInput!): Boolean!
|
||||
|
|
|
|||
|
|
@ -74,6 +74,29 @@ input SceneMovieInput {
|
|||
scene_index: Int
|
||||
}
|
||||
|
||||
input SceneCreateInput {
|
||||
title: String
|
||||
code: String
|
||||
details: String
|
||||
director: String
|
||||
url: String
|
||||
date: String
|
||||
rating: Int
|
||||
organized: Boolean
|
||||
studio_id: ID
|
||||
gallery_ids: [ID!]
|
||||
performer_ids: [ID!]
|
||||
movies: [SceneMovieInput!]
|
||||
tag_ids: [ID!]
|
||||
"""This should be a URL or a base64 encoded data URL"""
|
||||
cover_image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
|
||||
"""The first id will be assigned as primary. Files will be reassigned from
|
||||
existing scenes if applicable. Files must not already be primary for another scene"""
|
||||
file_ids: [ID!]
|
||||
}
|
||||
|
||||
input SceneUpdateInput {
|
||||
clientMutationId: String
|
||||
id: ID!
|
||||
|
|
@ -84,6 +107,7 @@ input SceneUpdateInput {
|
|||
url: String
|
||||
date: String
|
||||
rating: Int
|
||||
o_counter: Int
|
||||
organized: Boolean
|
||||
studio_id: ID
|
||||
gallery_ids: [ID!]
|
||||
|
|
@ -190,3 +214,17 @@ type SceneStreamEndpoint {
|
|||
mime_type: String
|
||||
label: String
|
||||
}
|
||||
|
||||
input AssignSceneFileInput {
|
||||
scene_id: ID!
|
||||
file_id: ID!
|
||||
}
|
||||
|
||||
input SceneMergeInput {
|
||||
"""If destination scene has no files, then the primary file of the
|
||||
first source scene will be assigned as primary"""
|
||||
source: [ID!]!
|
||||
destination: ID!
|
||||
# values defined here will override values in the destination
|
||||
values: SceneUpdateInput
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -19,19 +20,35 @@ func getArgumentMap(ctx context.Context) map[string]interface{} {
|
|||
}
|
||||
|
||||
func getUpdateInputMap(ctx context.Context) map[string]interface{} {
|
||||
return getNamedUpdateInputMap(ctx, updateInputField)
|
||||
}
|
||||
|
||||
func getNamedUpdateInputMap(ctx context.Context, field string) map[string]interface{} {
|
||||
args := getArgumentMap(ctx)
|
||||
|
||||
input := args[updateInputField]
|
||||
var ret map[string]interface{}
|
||||
if input != nil {
|
||||
ret, _ = input.(map[string]interface{})
|
||||
// field can be qualified
|
||||
fields := strings.Split(field, ".")
|
||||
|
||||
currArgs := args
|
||||
|
||||
for _, f := range fields {
|
||||
v, found := currArgs[f]
|
||||
if !found {
|
||||
currArgs = nil
|
||||
break
|
||||
}
|
||||
|
||||
currArgs, _ = v.(map[string]interface{})
|
||||
if currArgs == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ret == nil {
|
||||
ret = make(map[string]interface{})
|
||||
if currArgs != nil {
|
||||
return currArgs
|
||||
}
|
||||
|
||||
return ret
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
func getUpdateInputMaps(ctx context.Context) []map[string]interface{} {
|
||||
|
|
@ -90,6 +107,14 @@ func (t changesetTranslator) nullString(value *string, field string) *sql.NullSt
|
|||
return ret
|
||||
}
|
||||
|
||||
func (t changesetTranslator) string(value *string, field string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *value
|
||||
}
|
||||
|
||||
func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString {
|
||||
if !t.hasField(field) {
|
||||
return models.OptionalString{}
|
||||
|
|
@ -128,6 +153,27 @@ func (t changesetTranslator) optionalDate(value *string, field string) models.Op
|
|||
return models.NewOptionalDate(models.NewDate(*value))
|
||||
}
|
||||
|
||||
func (t changesetTranslator) datePtr(value *string, field string) *models.Date {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
d := models.NewDate(*value)
|
||||
return &d
|
||||
}
|
||||
|
||||
func (t changesetTranslator) intPtrFromString(value *string, field string) (*int, error) {
|
||||
if value == nil || *value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
vv, err := strconv.Atoi(*value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting %v to int: %w", *value, err)
|
||||
}
|
||||
return &vv, nil
|
||||
}
|
||||
|
||||
func (t changesetTranslator) nullInt64(value *int, field string) *sql.NullInt64 {
|
||||
if !t.hasField(field) {
|
||||
return nil
|
||||
|
|
@ -185,6 +231,14 @@ func (t changesetTranslator) optionalIntFromString(value *string, field string)
|
|||
return models.NewOptionalInt(vv), nil
|
||||
}
|
||||
|
||||
func (t changesetTranslator) bool(value *bool, field string) bool {
|
||||
if value == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return *value
|
||||
}
|
||||
|
||||
func (t changesetTranslator) optionalBool(value *bool, field string) models.OptionalBool {
|
||||
if !t.hasField(field) {
|
||||
return models.OptionalBool{}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) (
|
|||
obj.Files.SetPrimary(ret)
|
||||
|
||||
return ret, nil
|
||||
} else {
|
||||
_ = obj.LoadPrimaryFile(ctx, r.repository.File)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package api
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
|
@ -30,6 +31,79 @@ func (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Sc
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneCreate(ctx context.Context, input SceneCreateInput) (ret *models.Scene, err error) {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
performerIDs, err := stringslice.StringSliceToIntSlice(input.PerformerIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting performer ids: %w", err)
|
||||
}
|
||||
tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
galleryIDs, err := stringslice.StringSliceToIntSlice(input.GalleryIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting gallery ids: %w", err)
|
||||
}
|
||||
|
||||
moviesScenes, err := models.MoviesScenesFromInput(input.Movies)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting movies scenes: %w", err)
|
||||
}
|
||||
|
||||
fileIDsInt, err := stringslice.StringSliceToIntSlice(input.FileIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting file ids: %w", err)
|
||||
}
|
||||
|
||||
fileIDs := make([]file.ID, len(fileIDsInt))
|
||||
for i, v := range fileIDsInt {
|
||||
fileIDs[i] = file.ID(v)
|
||||
}
|
||||
|
||||
newScene := models.Scene{
|
||||
Title: translator.string(input.Title, "title"),
|
||||
Code: translator.string(input.Code, "code"),
|
||||
Details: translator.string(input.Details, "details"),
|
||||
Director: translator.string(input.Director, "director"),
|
||||
URL: translator.string(input.URL, "url"),
|
||||
Date: translator.datePtr(input.Date, "date"),
|
||||
Rating: input.Rating,
|
||||
Organized: translator.bool(input.Organized, "organized"),
|
||||
PerformerIDs: models.NewRelatedIDs(performerIDs),
|
||||
TagIDs: models.NewRelatedIDs(tagIDs),
|
||||
GalleryIDs: models.NewRelatedIDs(galleryIDs),
|
||||
Movies: models.NewRelatedMovies(moviesScenes),
|
||||
StashIDs: models.NewRelatedStashIDs(stashIDPtrSliceToSlice(input.StashIds)),
|
||||
}
|
||||
|
||||
newScene.StudioID, err = translator.intPtrFromString(input.StudioID, "studio_id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting studio id: %w", err)
|
||||
}
|
||||
|
||||
var coverImageData []byte
|
||||
if input.CoverImage != nil && *input.CoverImage != "" {
|
||||
var err error
|
||||
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.Resolver.sceneService.Create(ctx, &newScene, fileIDs, coverImageData)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (ret *models.Scene, err error) {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
|
|
@ -90,26 +164,7 @@ func (r *mutationResolver) ScenesUpdate(ctx context.Context, input []*models.Sce
|
|||
return newRet, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) {
|
||||
// Populate scene from the input
|
||||
sceneID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
qb := r.repository.Scene
|
||||
|
||||
s, err := qb.Find(ctx, sceneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("scene with id %d not found", sceneID)
|
||||
}
|
||||
|
||||
var coverImageData []byte
|
||||
|
||||
func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTranslator) (*models.ScenePartial, error) {
|
||||
updatedScene := models.NewScenePartial()
|
||||
updatedScene.Title = translator.optionalString(input.Title, "title")
|
||||
updatedScene.Code = translator.optionalString(input.Code, "code")
|
||||
|
|
@ -118,6 +173,8 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||
updatedScene.URL = translator.optionalString(input.URL, "url")
|
||||
updatedScene.Date = translator.optionalDate(input.Date, "date")
|
||||
updatedScene.Rating = translator.optionalInt(input.Rating, "rating")
|
||||
updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter")
|
||||
var err error
|
||||
updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting studio id: %w", err)
|
||||
|
|
@ -133,36 +190,6 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||
|
||||
converted := file.ID(primaryFileID)
|
||||
updatedScene.PrimaryFileID = &converted
|
||||
|
||||
// if file hash has changed, we should migrate generated files
|
||||
// after commit
|
||||
if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ensure that new primary file is associated with scene
|
||||
var f *file.VideoFile
|
||||
for _, ff := range s.Files.List() {
|
||||
if ff.ID == converted {
|
||||
f = ff
|
||||
}
|
||||
}
|
||||
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("file with id %d not associated with scene", converted)
|
||||
}
|
||||
|
||||
fileNamingAlgorithm := config.GetInstance().GetVideoFileNamingAlgorithm()
|
||||
oldHash := scene.GetHash(s.Files.Primary(), fileNamingAlgorithm)
|
||||
newHash := scene.GetHash(f, fileNamingAlgorithm)
|
||||
|
||||
if oldHash != "" && newHash != "" && oldHash != newHash {
|
||||
// perform migration after commit
|
||||
txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
|
||||
scene.MigrateHash(manager.GetInstance().Paths, oldHash, newHash)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("performer_ids") {
|
||||
|
|
@ -202,39 +229,107 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||
}
|
||||
}
|
||||
|
||||
return &updatedScene, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) {
|
||||
// Populate scene from the input
|
||||
sceneID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
qb := r.repository.Scene
|
||||
|
||||
s, err := qb.Find(ctx, sceneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("scene with id %d not found", sceneID)
|
||||
}
|
||||
|
||||
var coverImageData []byte
|
||||
|
||||
updatedScene, err := scenePartialFromInput(input, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ensure that title is set where scene has no file
|
||||
if updatedScene.Title.Set && updatedScene.Title.Value == "" {
|
||||
if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(s.Files.List()) == 0 {
|
||||
return nil, errors.New("title must be set if scene has no files")
|
||||
}
|
||||
}
|
||||
|
||||
if updatedScene.PrimaryFileID != nil {
|
||||
newPrimaryFileID := *updatedScene.PrimaryFileID
|
||||
|
||||
// if file hash has changed, we should migrate generated files
|
||||
// after commit
|
||||
if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ensure that new primary file is associated with scene
|
||||
var f *file.VideoFile
|
||||
for _, ff := range s.Files.List() {
|
||||
if ff.ID == newPrimaryFileID {
|
||||
f = ff
|
||||
}
|
||||
}
|
||||
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("file with id %d not associated with scene", newPrimaryFileID)
|
||||
}
|
||||
}
|
||||
|
||||
if input.CoverImage != nil && *input.CoverImage != "" {
|
||||
var err error
|
||||
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// update the cover after updating the scene
|
||||
}
|
||||
|
||||
s, err = qb.UpdatePartial(ctx, sceneID, updatedScene)
|
||||
s, err = qb.UpdatePartial(ctx, sceneID, *updatedScene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// update cover table
|
||||
if len(coverImageData) > 0 {
|
||||
if err := qb.UpdateCover(ctx, sceneID, coverImageData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// only update the cover image if provided and everything else was successful
|
||||
if coverImageData != nil {
|
||||
err = scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.sceneUpdateCoverImage(ctx, s, coverImageData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {
|
||||
if len(coverImageData) > 0 {
|
||||
qb := r.repository.Scene
|
||||
|
||||
// update cover table
|
||||
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Path != "" {
|
||||
// update the file-based screenshot after commit
|
||||
txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
|
||||
return scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneUpdateInput) ([]*models.Scene, error) {
|
||||
sceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
|
||||
if err != nil {
|
||||
|
|
@ -486,6 +581,84 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
|
|||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneAssignFile(ctx context.Context, input AssignSceneFileInput) (bool, error) {
|
||||
sceneID, err := strconv.Atoi(input.SceneID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("converting scene ID: %w", err)
|
||||
}
|
||||
|
||||
fileIDInt, err := strconv.Atoi(input.FileID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("converting file ID: %w", err)
|
||||
}
|
||||
|
||||
fileID := file.ID(fileIDInt)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
return r.Resolver.sceneService.AssignFile(ctx, sceneID, fileID)
|
||||
}); err != nil {
|
||||
return false, fmt.Errorf("assigning file to scene: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput) (*models.Scene, error) {
|
||||
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting source IDs: %w", err)
|
||||
}
|
||||
|
||||
destID, err := strconv.Atoi(input.Destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting destination ID %s: %w", input.Destination, err)
|
||||
}
|
||||
|
||||
var values *models.ScenePartial
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
|
||||
}
|
||||
|
||||
values, err = scenePartialFromInput(*input.Values, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
v := models.NewScenePartial()
|
||||
values = &v
|
||||
}
|
||||
|
||||
var coverImageData []byte
|
||||
|
||||
if input.Values.CoverImage != nil && *input.Values.CoverImage != "" {
|
||||
var err error
|
||||
coverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var ret *models.Scene
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
if err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, *values); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ret, err = r.Resolver.repository.Scene.Find(ctx, destID)
|
||||
|
||||
if err == nil && ret != nil {
|
||||
err = r.sceneUpdateCoverImage(ctx, ret, coverImageData)
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) getSceneMarker(ctx context.Context, id int) (ret *models.SceneMarker, err error) {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.SceneMarker.Find(ctx, id)
|
||||
|
|
|
|||
|
|
@ -169,6 +169,9 @@ func initialize() error {
|
|||
|
||||
db := sqlite.NewDatabase()
|
||||
|
||||
// start with empty paths
|
||||
emptyPaths := paths.Paths{}
|
||||
|
||||
instance = &Manager{
|
||||
Config: cfg,
|
||||
Logger: l,
|
||||
|
|
@ -178,14 +181,18 @@ func initialize() error {
|
|||
|
||||
Database: db,
|
||||
Repository: sqliteRepository(db),
|
||||
Paths: &emptyPaths,
|
||||
|
||||
scanSubs: &subscriptionManager{},
|
||||
}
|
||||
|
||||
instance.SceneService = &scene.Service{
|
||||
File: db.File,
|
||||
Repository: db.Scene,
|
||||
MarkerDestroyer: instance.Repository.SceneMarker,
|
||||
File: db.File,
|
||||
Repository: db.Scene,
|
||||
MarkerRepository: instance.Repository.SceneMarker,
|
||||
PluginCache: instance.PluginCache,
|
||||
Paths: instance.Paths,
|
||||
Config: cfg,
|
||||
}
|
||||
|
||||
instance.ImageService = &image.Service{
|
||||
|
|
@ -444,7 +451,7 @@ func (s *Manager) PostInit(ctx context.Context) error {
|
|||
logger.Warnf("could not set initial configuration: %v", err)
|
||||
}
|
||||
|
||||
s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
|
||||
*s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
|
||||
s.RefreshConfig()
|
||||
s.SessionStore = session.NewStore(s.Config)
|
||||
s.PluginCache.RegisterSessionStore(s.SessionStore)
|
||||
|
|
@ -518,7 +525,7 @@ func (s *Manager) initScraperCache() *scraper.Cache {
|
|||
}
|
||||
|
||||
func (s *Manager) RefreshConfig() {
|
||||
s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
|
||||
*s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
|
||||
config := s.Config
|
||||
if config.Validate() == nil {
|
||||
if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ func sqliteRepository(d *sqlite.Database) Repository {
|
|||
}
|
||||
|
||||
type SceneService interface {
|
||||
Create(ctx context.Context, input *models.Scene, fileIDs []file.ID, coverImage []byte) (*models.Scene, error)
|
||||
AssignFile(ctx context.Context, sceneID int, fileID file.ID) error
|
||||
Merge(ctx context.Context, sourceIDs []int, destinationID int, values models.ScenePartial) error
|
||||
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,13 +91,15 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit
|
|||
func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) {
|
||||
const defaultSceneImage = "scene/scene.svg"
|
||||
|
||||
filepath := GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
||||
if scene.Path != "" {
|
||||
filepath := GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
||||
|
||||
// fall back to the scene image blob if the file isn't present
|
||||
screenshotExists, _ := fsutil.FileExists(filepath)
|
||||
if screenshotExists {
|
||||
http.ServeFile(w, r, filepath)
|
||||
return
|
||||
// fall back to the scene image blob if the file isn't present
|
||||
screenshotExists, _ := fsutil.FileExists(filepath)
|
||||
if screenshotExists {
|
||||
http.ServeFile(w, r, filepath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var cover []byte
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea
|
|||
|
||||
pf := scene.Files.Primary()
|
||||
if pf == nil {
|
||||
return nil, fmt.Errorf("nil file")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var ret []*SceneStreamEndpoint
|
||||
|
|
|
|||
|
|
@ -697,6 +697,11 @@ func (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) {
|
|||
defer wg.Done()
|
||||
r := t.txnManager
|
||||
if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error {
|
||||
if t.scene.Path == "" {
|
||||
// nothing to do
|
||||
return nil
|
||||
}
|
||||
|
||||
if t.performers {
|
||||
if err := autotag.ScenePerformers(ctx, t.scene, r.Scene, r.Performer, t.cache); err != nil {
|
||||
return fmt.Errorf("error tagging scene performers for %s: %v", t.scene.DisplayName(), err)
|
||||
|
|
|
|||
|
|
@ -583,7 +583,7 @@ func exportScene(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models
|
|||
basename := filepath.Base(s.Path)
|
||||
hash := s.OSHash
|
||||
|
||||
fn := newSceneJSON.Filename(basename, hash)
|
||||
fn := newSceneJSON.Filename(s.ID, basename, hash)
|
||||
|
||||
if err := t.json.saveScene(fn, newSceneJSON); err != nil {
|
||||
logger.Errorf("[scenes] <%s> failed to save json: %s", sceneHash, err.Error())
|
||||
|
|
|
|||
|
|
@ -295,21 +295,23 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
|
|||
generator: g,
|
||||
}
|
||||
|
||||
sceneHash := scene.GetHash(task.fileNamingAlgorithm)
|
||||
addTask := false
|
||||
if j.overwrite || !task.doesVideoPreviewExist(sceneHash) {
|
||||
totals.previews++
|
||||
addTask = true
|
||||
}
|
||||
if task.required() {
|
||||
sceneHash := scene.GetHash(task.fileNamingAlgorithm)
|
||||
addTask := false
|
||||
if j.overwrite || !task.doesVideoPreviewExist(sceneHash) {
|
||||
totals.previews++
|
||||
addTask = true
|
||||
}
|
||||
|
||||
if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist(sceneHash)) {
|
||||
totals.imagePreviews++
|
||||
addTask = true
|
||||
}
|
||||
if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist(sceneHash)) {
|
||||
totals.imagePreviews++
|
||||
addTask = true
|
||||
}
|
||||
|
||||
if addTask {
|
||||
totals.tasks++
|
||||
queue <- task
|
||||
if addTask {
|
||||
totals.tasks++
|
||||
queue <- task
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) shouldGenerate() bool {
|
|||
return false
|
||||
}
|
||||
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
|
||||
return !t.doesHeatmapExist(sceneHash) || t.Overwrite
|
||||
return !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil || t.Overwrite
|
||||
}
|
||||
|
||||
func (t *GenerateInteractiveHeatmapSpeedTask) doesHeatmapExist(sceneChecksum string) bool {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -45,6 +45,10 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) {
|
|||
if err := t.TxnManager.WithTxn(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
scene, err = t.TxnManager.Scene.Find(ctx, int(t.Marker.SceneID.Int64))
|
||||
if err == nil && scene != nil {
|
||||
err = scene.LoadPrimaryFile(ctx, t.TxnManager.File)
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
logger.Errorf("error finding scene for marker: %s", err.Error())
|
||||
|
|
@ -56,10 +60,10 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
ffprobe := instance.FFProbe
|
||||
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
|
||||
if err != nil {
|
||||
logger.Errorf("error reading video file: %s", err.Error())
|
||||
videoFile := scene.Files.Primary()
|
||||
|
||||
if videoFile == nil {
|
||||
// nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -78,14 +82,9 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if len(sceneMarkers) == 0 {
|
||||
return
|
||||
}
|
||||
videoFile := t.Scene.Files.Primary()
|
||||
|
||||
ffprobe := instance.FFProbe
|
||||
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
|
||||
if err != nil {
|
||||
logger.Errorf("error reading video file: %s", err.Error())
|
||||
if len(sceneMarkers) == 0 || videoFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +104,7 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) {
|
||||
func (t *GenerateMarkersTask) generateMarker(videoFile *file.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) {
|
||||
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
|
||||
seconds := int(sceneMarker.Seconds)
|
||||
|
||||
|
|
@ -139,7 +138,7 @@ func (t *GenerateMarkersTask) markersNeeded(ctx context.Context) int {
|
|||
return 0
|
||||
}
|
||||
|
||||
if len(sceneMarkers) == 0 {
|
||||
if len(sceneMarkers) == 0 || t.Scene.Files.Primary() == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ func (t GeneratePreviewTask) generateWebp(videoChecksum string) error {
|
|||
}
|
||||
|
||||
func (t GeneratePreviewTask) required() bool {
|
||||
if t.Scene.Path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
|
||||
videoExists := t.doesVideoPreviewExist(sceneHash)
|
||||
imageExists := !t.ImagePreview || t.doesImagePreviewExist(sceneHash)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) {
|
|||
scenePath := t.Scene.Path
|
||||
|
||||
videoFile := t.Scene.Files.Primary()
|
||||
if videoFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var at float64
|
||||
if t.ScreenshotAt == nil {
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ type Finder interface {
|
|||
|
||||
// Getter provides methods to find Files.
|
||||
type Getter interface {
|
||||
Finder
|
||||
FindByPath(ctx context.Context, path string) (File, error)
|
||||
FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error)
|
||||
FindByZipFileID(ctx context.Context, zipFileID ID) ([]File, error)
|
||||
|
|
@ -190,6 +191,8 @@ type Store interface {
|
|||
Creator
|
||||
Updater
|
||||
Destroyer
|
||||
|
||||
IsPrimary(ctx context.Context, fileID ID) (bool, error)
|
||||
}
|
||||
|
||||
// Decorator wraps the Decorate method to add additional functionality while scanning files.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package jsonschema
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
|
|
@ -60,7 +61,7 @@ type Scene struct {
|
|||
StashIDs []models.StashID `json:"stash_ids,omitempty"`
|
||||
}
|
||||
|
||||
func (s Scene) Filename(basename string, hash string) string {
|
||||
func (s Scene) Filename(id int, basename string, hash string) string {
|
||||
ret := fsutil.SanitiseBasename(s.Title)
|
||||
if ret == "" {
|
||||
ret = basename
|
||||
|
|
@ -68,6 +69,9 @@ func (s Scene) Filename(basename string, hash string) string {
|
|||
|
||||
if hash != "" {
|
||||
ret += "." + hash
|
||||
} else {
|
||||
// scenes may have no file and therefore no hash
|
||||
ret += "." + strconv.Itoa(id)
|
||||
}
|
||||
|
||||
return ret + ".json"
|
||||
|
|
|
|||
|
|
@ -41,21 +41,43 @@ func (u *UpdateMovieIDs) SceneMovieInputs() []*SceneMovieInput {
|
|||
return ret
|
||||
}
|
||||
|
||||
func (u *UpdateMovieIDs) AddUnique(v MoviesScenes) {
|
||||
for _, vv := range u.Movies {
|
||||
if vv.MovieID == v.MovieID {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
u.Movies = append(u.Movies, v)
|
||||
}
|
||||
|
||||
func UpdateMovieIDsFromInput(i []*SceneMovieInput) (*UpdateMovieIDs, error) {
|
||||
ret := &UpdateMovieIDs{
|
||||
Mode: RelationshipUpdateModeSet,
|
||||
}
|
||||
|
||||
for _, v := range i {
|
||||
var err error
|
||||
ret.Movies, err = MoviesScenesFromInput(i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func MoviesScenesFromInput(input []*SceneMovieInput) ([]MoviesScenes, error) {
|
||||
ret := make([]MoviesScenes, len(input))
|
||||
|
||||
for i, v := range input {
|
||||
mID, err := strconv.Atoi(v.MovieID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid movie ID: %s", v.MovieID)
|
||||
}
|
||||
|
||||
ret.Movies = append(ret.Movies, MoviesScenes{
|
||||
ret[i] = MoviesScenes{
|
||||
MovieID: mID,
|
||||
SceneIndex: v.SceneIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ type SceneUpdateInput struct {
|
|||
URL *string `json:"url"`
|
||||
Date *string `json:"date"`
|
||||
Rating *int `json:"rating"`
|
||||
OCounter *int `json:"o_counter"`
|
||||
Organized *bool `json:"organized"`
|
||||
StudioID *string `json:"studio_id"`
|
||||
GalleryIds []string `json:"gallery_ids"`
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ type Paths struct {
|
|||
SceneMarkers *sceneMarkerPaths
|
||||
}
|
||||
|
||||
func NewPaths(generatedPath string) *Paths {
|
||||
func NewPaths(generatedPath string) Paths {
|
||||
p := Paths{}
|
||||
p.Generated = newGeneratedPaths(generatedPath)
|
||||
|
||||
p.Scene = newScenePaths(p)
|
||||
p.SceneMarkers = newSceneMarkerPaths(p)
|
||||
return &p
|
||||
return p
|
||||
}
|
||||
|
||||
func GetStashHomeDirectory() string {
|
||||
|
|
|
|||
|
|
@ -138,6 +138,19 @@ func (r *RelatedMovies) Add(movies ...MoviesScenes) {
|
|||
r.list = append(r.list, movies...)
|
||||
}
|
||||
|
||||
// ForID returns the MoviesScenes object for the given movie ID. Returns nil if not found.
|
||||
func (r *RelatedMovies) ForID(id int) *MoviesScenes {
|
||||
r.mustLoaded()
|
||||
|
||||
for _, v := range r.list {
|
||||
if v.MovieID == id {
|
||||
return &v
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RelatedMovies) load(fn func() ([]MoviesScenes, error)) error {
|
||||
if r.Loaded() {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -9,3 +9,14 @@ type UpdateStashIDs struct {
|
|||
StashIDs []StashID `json:"stash_ids"`
|
||||
Mode RelationshipUpdateMode `json:"mode"`
|
||||
}
|
||||
|
||||
// AddUnique adds the stash id to the list, only if the endpoint/stashid pair does not already exist in the list.
|
||||
func (u *UpdateStashIDs) AddUnique(v StashID) {
|
||||
for _, vv := range u.StashIDs {
|
||||
if vv.StashID == v.StashID && vv.Endpoint == v.Endpoint {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
u.StashIDs = append(u.StashIDs, v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ func (o *OptionalString) Ptr() *string {
|
|||
return &v
|
||||
}
|
||||
|
||||
// Merge sets the OptionalString if it is not already set, the destination value is empty and the source value is not empty.
|
||||
func (o *OptionalString) Merge(destVal string, srcVal string) {
|
||||
if destVal == "" && srcVal != "" && !o.Set {
|
||||
*o = NewOptionalString(srcVal)
|
||||
}
|
||||
}
|
||||
|
||||
// NewOptionalString returns a new OptionalString with the given value.
|
||||
func NewOptionalString(v string) OptionalString {
|
||||
return OptionalString{v, false, true}
|
||||
|
|
@ -58,6 +65,13 @@ func (o *OptionalInt) Ptr() *int {
|
|||
return &v
|
||||
}
|
||||
|
||||
// MergePtr sets the OptionalInt if it is not already set, the destination value is nil and the source value is not nil.
|
||||
func (o *OptionalInt) MergePtr(destVal *int, srcVal *int) {
|
||||
if destVal == nil && srcVal != nil && !o.Set {
|
||||
*o = NewOptionalInt(*srcVal)
|
||||
}
|
||||
}
|
||||
|
||||
// NewOptionalInt returns a new OptionalInt with the given value.
|
||||
func NewOptionalInt(v int) OptionalInt {
|
||||
return OptionalInt{v, false, true}
|
||||
|
|
@ -138,6 +152,13 @@ func (o *OptionalBool) Ptr() *bool {
|
|||
return &v
|
||||
}
|
||||
|
||||
// Merge sets the OptionalBool to true if it is not already set, the destination value is false and the source value is true.
|
||||
func (o *OptionalBool) Merge(destVal bool, srcVal bool) {
|
||||
if !destVal && srcVal && !o.Set {
|
||||
*o = NewOptionalBool(true)
|
||||
}
|
||||
}
|
||||
|
||||
// NewOptionalBool returns a new OptionalBool with the given value.
|
||||
func NewOptionalBool(v bool) OptionalBool {
|
||||
return OptionalBool{v, false, true}
|
||||
|
|
@ -200,6 +221,13 @@ func NewOptionalDate(v Date) OptionalDate {
|
|||
return OptionalDate{v, false, true}
|
||||
}
|
||||
|
||||
// Merge sets the OptionalDate if it is not already set, the destination value is nil and the source value is nil.
|
||||
func (o *OptionalDate) MergePtr(destVal *Date, srcVal *Date) {
|
||||
if destVal == nil && srcVal != nil && !o.Set {
|
||||
*o = NewOptionalDate(*srcVal)
|
||||
}
|
||||
}
|
||||
|
||||
// NewOptionalBoolPtr returns a new OptionalDate with the given value.
|
||||
// If the value is nil, the returned OptionalDate will be set and null.
|
||||
func NewOptionalDatePtr(v *Date) OptionalDate {
|
||||
|
|
|
|||
76
pkg/scene/create.go
Normal file
76
pkg/scene/create.go
Normal 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
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ type MarkerDestroyer interface {
|
|||
// Destroy deletes a scene and its associated relationships from the
|
||||
// database.
|
||||
func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
|
||||
mqb := s.MarkerDestroyer
|
||||
mqb := s.MarkerRepository
|
||||
markers, err := mqb.FindBySceneID(ctx, scene.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
144
pkg/scene/merge.go
Normal file
144
pkg/scene/merge.go
Normal 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
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ type Queryer interface {
|
|||
|
||||
type IDFinder interface {
|
||||
Find(ctx context.Context, id int) (*models.Scene, error)
|
||||
FindMany(ctx context.Context, ids []int) ([]*models.Scene, error)
|
||||
}
|
||||
|
||||
// QueryOptions returns a SceneQueryOptions populated with the provided filters.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ var (
|
|||
type CreatorUpdater interface {
|
||||
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error)
|
||||
FindByFingerprints(ctx context.Context, fp []file.Fingerprint) ([]*models.Scene, error)
|
||||
Create(ctx context.Context, newScene *models.Scene, fileIDs []file.ID) error
|
||||
Creator
|
||||
UpdatePartial(ctx context.Context, id int, updatedScene models.ScenePartial) (*models.Scene, error)
|
||||
AddFileID(ctx context.Context, id int, fileID file.ID) error
|
||||
models.VideoFileLoader
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ type PathsCoverSetter struct {
|
|||
}
|
||||
|
||||
func (ss *PathsCoverSetter) SetScreenshot(scene *models.Scene, imageData []byte) error {
|
||||
// don't set where scene has no file
|
||||
if scene.Path == "" {
|
||||
return nil
|
||||
}
|
||||
checksum := scene.GetHash(ss.FileNamingAlgorithm)
|
||||
return SetScreenshot(ss.Paths, checksum, imageData)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,55 @@ import (
|
|||
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/paths"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
)
|
||||
|
||||
type FinderByFile interface {
|
||||
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error)
|
||||
}
|
||||
|
||||
type FileAssigner interface {
|
||||
AssignFiles(ctx context.Context, sceneID int, fileID []file.ID) error
|
||||
}
|
||||
|
||||
type Creator interface {
|
||||
Create(ctx context.Context, newScene *models.Scene, fileIDs []file.ID) error
|
||||
}
|
||||
|
||||
type CoverUpdater interface {
|
||||
UpdateCover(ctx context.Context, sceneID int, cover []byte) error
|
||||
}
|
||||
|
||||
type Config interface {
|
||||
GetVideoFileNamingAlgorithm() models.HashAlgorithm
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
IDFinder
|
||||
FinderByFile
|
||||
Creator
|
||||
PartialUpdater
|
||||
Destroyer
|
||||
models.VideoFileLoader
|
||||
FileAssigner
|
||||
CoverUpdater
|
||||
models.SceneReader
|
||||
}
|
||||
|
||||
type MarkerRepository interface {
|
||||
MarkerFinder
|
||||
MarkerDestroyer
|
||||
|
||||
Update(ctx context.Context, updatedObject models.SceneMarker) (*models.SceneMarker, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
File file.Store
|
||||
Repository Repository
|
||||
MarkerDestroyer MarkerDestroyer
|
||||
File file.Store
|
||||
Repository Repository
|
||||
MarkerRepository MarkerRepository
|
||||
PluginCache *plugin.Cache
|
||||
|
||||
Paths *paths.Paths
|
||||
Config Config
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
|
@ -115,3 +116,27 @@ func AddGallery(ctx context.Context, qb PartialUpdater, o *models.Scene, gallery
|
|||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) AssignFile(ctx context.Context, sceneID int, fileID file.ID) error {
|
||||
// ensure file isn't a primary file and that it is a video file
|
||||
f, err := s.File.Find(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ff := f[0]
|
||||
if _, ok := ff.(*file.VideoFile); !ok {
|
||||
return fmt.Errorf("%s is not a video file", ff.Base().Path)
|
||||
}
|
||||
|
||||
isPrimary, err := s.File.IsPrimary(ctx, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isPrimary {
|
||||
return errors.New("cannot reassign primary file")
|
||||
}
|
||||
|
||||
return s.Repository.AssignFiles(ctx, sceneID, []file.ID{fileID})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scen
|
|||
// populate performers, studio and tags based on scene path
|
||||
if err := txn.WithTxn(ctx, s.txnManager, func(ctx context.Context) error {
|
||||
path := scene.Path
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("autotag scraper viaScene: %w", err)
|
||||
|
|
|
|||
|
|
@ -189,13 +189,22 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, ids []int)
|
|||
}
|
||||
|
||||
func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*scraper.ScrapedScene, error) {
|
||||
var ret [][]*scraper.ScrapedScene
|
||||
for i := 0; i < len(scenes); i += 40 {
|
||||
end := i + 40
|
||||
if end > len(scenes) {
|
||||
end = len(scenes)
|
||||
var results [][]*scraper.ScrapedScene
|
||||
|
||||
// filter out nils
|
||||
var validScenes [][]*graphql.FingerprintQueryInput
|
||||
for _, s := range scenes {
|
||||
if len(s) > 0 {
|
||||
validScenes = append(validScenes, s)
|
||||
}
|
||||
scenes, err := c.client.FindScenesBySceneFingerprints(ctx, scenes[i:end])
|
||||
}
|
||||
|
||||
for i := 0; i < len(validScenes); i += 40 {
|
||||
end := i + 40
|
||||
if end > len(validScenes) {
|
||||
end = len(validScenes)
|
||||
}
|
||||
scenes, err := c.client.FindScenesBySceneFingerprints(ctx, validScenes[i:end])
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -210,11 +219,22 @@ func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][
|
|||
}
|
||||
sceneResults = append(sceneResults, ss)
|
||||
}
|
||||
ret = append(ret, sceneResults)
|
||||
results = append(results, sceneResults)
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
// repopulate the results to be the same order as the input
|
||||
ret := make([][]*scraper.ScrapedScene, len(scenes))
|
||||
upTo := 0
|
||||
|
||||
for i, v := range scenes {
|
||||
if len(v) > 0 {
|
||||
ret[i] = results[upTo]
|
||||
upTo++
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c Client) SubmitStashBoxFingerprints(ctx context.Context, sceneIDs []string, endpoint string) (bool, error) {
|
||||
|
|
|
|||
|
|
@ -746,7 +746,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima
|
|||
aggregateQuery := qb.newQuery()
|
||||
|
||||
if options.Count {
|
||||
aggregateQuery.addColumn("COUNT(temp.id) as total")
|
||||
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
|
||||
}
|
||||
|
||||
// TODO - this doesn't work yet
|
||||
|
|
|
|||
|
|
@ -975,7 +975,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce
|
|||
aggregateQuery := qb.newQuery()
|
||||
|
||||
if options.Count {
|
||||
aggregateQuery.addColumn("COUNT(temp.id) as total")
|
||||
aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total")
|
||||
}
|
||||
|
||||
if options.TotalDuration {
|
||||
|
|
@ -1432,6 +1432,22 @@ func (qb *SceneStore) DestroyCover(ctx context.Context, sceneID int) error {
|
|||
return qb.imageRepository().destroy(ctx, []int{sceneID})
|
||||
}
|
||||
|
||||
func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []file.ID) error {
|
||||
// assuming a file can only be assigned to a single scene
|
||||
if err := scenesFilesTableMgr.destroyJoins(ctx, fileIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// assign primary only if destination has no files
|
||||
existingFileIDs, err := qb.filesRepository().get(ctx, sceneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
firstPrimary := len(existingFileIDs) == 0
|
||||
return scenesFilesTableMgr.insertJoins(ctx, sceneID, firstPrimary, fileIDs)
|
||||
}
|
||||
|
||||
func (qb *SceneStore) moviesRepository() *repository {
|
||||
return &repository{
|
||||
tx: qb.tx,
|
||||
|
|
|
|||
|
|
@ -4077,5 +4077,47 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestSceneStore_AssignFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sceneID int
|
||||
fileID file.ID
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"valid",
|
||||
sceneIDs[sceneIdx1WithPerformer],
|
||||
sceneFileIDs[sceneIdx1WithStudio],
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid file id",
|
||||
sceneIDs[sceneIdx1WithPerformer],
|
||||
invalidFileID,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid scene id",
|
||||
invalidID,
|
||||
sceneFileIDs[sceneIdx1WithStudio],
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
qb := db.Scene
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
withRollbackTxn(func(ctx context.Context) error {
|
||||
if err := qb.AssignFiles(ctx, tt.sceneID, []file.ID{tt.fileID}); (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneStore.AssignFiles() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Count
|
||||
// TODO SizeCount
|
||||
|
|
|
|||
|
|
@ -529,6 +529,17 @@ func (t *relatedFilesTable) replaceJoins(ctx context.Context, id int, fileIDs []
|
|||
return t.insertJoins(ctx, id, firstPrimary, fileIDs)
|
||||
}
|
||||
|
||||
// destroyJoins destroys all entries in the table with the provided fileIDs
|
||||
func (t *relatedFilesTable) destroyJoins(ctx context.Context, fileIDs []file.ID) error {
|
||||
q := dialect.Delete(t.table.table).Where(t.table.table.Col("file_id").In(fileIDs))
|
||||
|
||||
if _, err := exec(ctx, q); err != nil {
|
||||
return fmt.Errorf("destroying file joins in %s: %w", t.table.table.GetTable(), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *relatedFilesTable) setPrimary(ctx context.Context, id int, fileID file.ID) error {
|
||||
table := t.table.table
|
||||
|
||||
|
|
|
|||
|
|
@ -566,8 +566,9 @@ export const GalleryEditPanel: React.FC<
|
|||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<SceneSelect
|
||||
scenes={scenes}
|
||||
selected={scenes}
|
||||
onSelect={(items) => onSetScenes(items)}
|
||||
isMulti
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ const allMenuItems: IMenuItem[] = [
|
|||
href: "/scenes",
|
||||
icon: faPlayCircle,
|
||||
hotkey: "g s",
|
||||
userCreatable: true,
|
||||
},
|
||||
{
|
||||
name: "images",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import {
|
|||
faTag,
|
||||
faTrash,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { SceneMergeModal } from "../Scenes/SceneMergeDialog";
|
||||
import { objectTitle } from "src/core/files";
|
||||
|
||||
const CLASSNAME = "duplicate-checker";
|
||||
|
||||
|
|
@ -75,6 +77,10 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
},
|
||||
scene_filter: {
|
||||
is_missing: "phash",
|
||||
file_count: {
|
||||
modifier: GQL.CriterionModifier.GreaterThan,
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -83,6 +89,10 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
GQL.SlimSceneDataFragment[] | null
|
||||
>(null);
|
||||
|
||||
const [mergeScenes, setMergeScenes] = useState<
|
||||
{ id: string; title: string }[] | undefined
|
||||
>(undefined);
|
||||
|
||||
if (loading) return <LoadingIndicator />;
|
||||
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 (
|
||||
<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}>
|
||||
{deletingScenes && selectedScenes && (
|
||||
<DeleteScenesDialog
|
||||
|
|
@ -399,6 +459,7 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
onClose={onDeleteDialogClosed}
|
||||
/>
|
||||
)}
|
||||
{renderMergeDialog()}
|
||||
{maybeRenderEdit()}
|
||||
<h4>
|
||||
<FormattedMessage id="dupe_check.title" />
|
||||
|
|
@ -548,6 +609,12 @@ export const SceneDuplicateChecker: React.FC = () => {
|
|||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
<Button
|
||||
className="edit-button"
|
||||
onClick={() => onMergeClicked(group, scene)}
|
||||
>
|
||||
<FormattedMessage id="actions.merge" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -360,6 +360,16 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
if (!props.compact && props.zoomIndex !== undefined) {
|
||||
return `zoom-${props.zoomIndex}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function filelessClass() {
|
||||
if (!props.scene.files.length) {
|
||||
return "fileless";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
const cont = configuration?.interface.continuePlaylistDefault ?? false;
|
||||
|
|
@ -373,7 +383,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
|
|||
|
||||
return (
|
||||
<GridCard
|
||||
className={`scene-card ${zoomIndex()}`}
|
||||
className={`scene-card ${zoomIndex()} ${filelessClass()}`}
|
||||
url={sceneLink}
|
||||
title={objectTitle(props.scene)}
|
||||
linkClassName="scene-card-link"
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ import { objectPath, objectTitle } from "src/core/files";
|
|||
|
||||
interface IProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
refetch: () => void;
|
||||
setTimestamp: (num: number) => void;
|
||||
queueScenes: QueuedScene[];
|
||||
onQueueNext: () => void;
|
||||
|
|
@ -81,7 +80,6 @@ interface IProps {
|
|||
|
||||
const ScenePage: React.FC<IProps> = ({
|
||||
scene,
|
||||
refetch,
|
||||
setTimestamp,
|
||||
queueScenes,
|
||||
onQueueNext,
|
||||
|
|
@ -260,13 +258,15 @@ const ScenePage: React.FC<IProps> = ({
|
|||
<Icon icon={faEllipsisV} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="bg-secondary text-white">
|
||||
<Dropdown.Item
|
||||
key="rescan"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() => onRescan()}
|
||||
>
|
||||
<FormattedMessage id="actions.rescan" />
|
||||
</Dropdown.Item>
|
||||
{!!scene.files.length && (
|
||||
<Dropdown.Item
|
||||
key="rescan"
|
||||
className="bg-secondary text-white"
|
||||
onClick={() => onRescan()}
|
||||
>
|
||||
<FormattedMessage id="actions.rescan" />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item
|
||||
key="generate"
|
||||
className="bg-secondary text-white"
|
||||
|
|
@ -449,7 +449,6 @@ const ScenePage: React.FC<IProps> = ({
|
|||
isVisible={activeTabKey === "scene-edit-panel"}
|
||||
scene={scene}
|
||||
onDelete={() => setIsDeleteAlertOpen(true)}
|
||||
onUpdate={refetch}
|
||||
/>
|
||||
</Tab.Pane>
|
||||
</Tab.Content>
|
||||
|
|
@ -511,7 +510,7 @@ const SceneLoader: React.FC = () => {
|
|||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { configuration } = useContext(ConfigurationContext);
|
||||
const { data, loading, refetch } = useFindScene(id ?? "");
|
||||
const { data, loading } = useFindScene(id ?? "");
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => queryString.parse(location.search, { decode: false }),
|
||||
|
|
@ -732,7 +731,6 @@ const SceneLoader: React.FC = () => {
|
|||
{!loading && scene ? (
|
||||
<ScenePage
|
||||
scene={scene}
|
||||
refetch={refetch}
|
||||
setTimestamp={setTimestamp}
|
||||
queueScenes={queueScenes ?? []}
|
||||
queueStart={queueStart}
|
||||
|
|
|
|||
74
ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx
Normal file
74
ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx
Normal 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;
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
useSceneUpdate,
|
||||
mutateReloadScrapers,
|
||||
queryScrapeSceneQueryFragment,
|
||||
mutateCreateScene,
|
||||
} from "src/core/StashService";
|
||||
import {
|
||||
PerformerSelect,
|
||||
|
|
@ -34,7 +35,8 @@ import useToast from "src/hooks/Toast";
|
|||
import { ImageUtils, FormUtils, getStashIDs } from "src/utils";
|
||||
import { MovieSelect } from "src/components/Shared/Select";
|
||||
import { useFormik } from "formik";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import { Prompt, useHistory } from "react-router-dom";
|
||||
import queryString from "query-string";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { stashboxDisplayName } from "src/utils/stashbox";
|
||||
import { SceneMovieTable } from "./SceneMovieTable";
|
||||
|
|
@ -50,19 +52,28 @@ const SceneScrapeDialog = lazy(() => import("./SceneScrapeDialog"));
|
|||
const SceneQueryModal = lazy(() => import("./SceneQueryModal"));
|
||||
|
||||
interface IProps {
|
||||
scene: GQL.SceneDataFragment;
|
||||
scene: Partial<GQL.SceneDataFragment>;
|
||||
initialCoverImage?: string;
|
||||
isNew?: boolean;
|
||||
isVisible: boolean;
|
||||
onDelete: () => void;
|
||||
onUpdate?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export const SceneEditPanel: React.FC<IProps> = ({
|
||||
scene,
|
||||
initialCoverImage,
|
||||
isNew = false,
|
||||
isVisible,
|
||||
onDelete,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const history = useHistory();
|
||||
|
||||
const queryParams = queryString.parse(location.search);
|
||||
|
||||
const fileID = (queryParams?.file_id ?? "") as string;
|
||||
|
||||
const [galleries, setGalleries] = useState<{ id: string; title: string }[]>(
|
||||
[]
|
||||
);
|
||||
|
|
@ -84,15 +95,17 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
>();
|
||||
|
||||
useEffect(() => {
|
||||
setCoverImagePreview(scene.paths.screenshot ?? undefined);
|
||||
}, [scene.paths.screenshot]);
|
||||
setCoverImagePreview(
|
||||
initialCoverImage ?? scene.paths?.screenshot ?? undefined
|
||||
);
|
||||
}, [scene.paths?.screenshot, initialCoverImage]);
|
||||
|
||||
useEffect(() => {
|
||||
setGalleries(
|
||||
scene.galleries.map((g) => ({
|
||||
scene.galleries?.map((g) => ({
|
||||
id: g.id,
|
||||
title: objectTitle(g),
|
||||
}))
|
||||
})) ?? []
|
||||
);
|
||||
}, [scene.galleries]);
|
||||
|
||||
|
|
@ -142,10 +155,10 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
return { movie_id: m.movie.id, scene_index: m.scene_index };
|
||||
}),
|
||||
tag_ids: (scene.tags ?? []).map((t) => t.id),
|
||||
cover_image: undefined,
|
||||
cover_image: initialCoverImage,
|
||||
stash_ids: getStashIDs(scene.stash_ids),
|
||||
}),
|
||||
[scene]
|
||||
[scene, initialCoverImage]
|
||||
);
|
||||
|
||||
type InputValues = typeof initialValues;
|
||||
|
|
@ -154,7 +167,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
initialValues,
|
||||
enableReinitialize: true,
|
||||
validationSchema: schema,
|
||||
onSubmit: (values) => onSave(getSceneInput(values)),
|
||||
onSubmit: (values) => onSave(values),
|
||||
});
|
||||
|
||||
function setRating(v: number) {
|
||||
|
|
@ -180,7 +193,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
formik.handleSubmit();
|
||||
});
|
||||
Mousetrap.bind("d d", () => {
|
||||
onDelete();
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
});
|
||||
|
||||
// numeric keypresses get caught by jwplayer, so blur the element
|
||||
|
|
@ -234,7 +249,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
|
||||
function getSceneInput(input: InputValues): GQL.SceneUpdateInput {
|
||||
return {
|
||||
id: scene.id,
|
||||
id: scene.id!,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
|
@ -256,27 +271,49 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
formik.setFieldValue("movies", newMovies);
|
||||
}
|
||||
|
||||
async function onSave(input: GQL.SceneUpdateInput) {
|
||||
function getCreateValues(values: InputValues): GQL.SceneCreateInput {
|
||||
return {
|
||||
...values,
|
||||
};
|
||||
}
|
||||
|
||||
async function onSave(input: InputValues) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateScene({
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
rating: input.rating ?? null,
|
||||
if (!isNew) {
|
||||
const updateValues = getSceneInput(input);
|
||||
const result = await updateScene({
|
||||
variables: {
|
||||
input: {
|
||||
...updateValues,
|
||||
id: scene.id!,
|
||||
rating: input.rating ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (result.data?.sceneUpdate) {
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{ entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() }
|
||||
),
|
||||
});
|
||||
// clear the cover image so that it doesn't appear dirty
|
||||
formik.resetForm({ values: formik.values });
|
||||
if (result.data?.sceneUpdate) {
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "toast.updated_entity" },
|
||||
{
|
||||
entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(),
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const createValues = getCreateValues(input);
|
||||
const result = await mutateCreateScene({
|
||||
...createValues,
|
||||
file_ids: fileID ? [fileID as string] : undefined,
|
||||
});
|
||||
if (result.data?.sceneCreate?.id) {
|
||||
history.push(`/scenes/${result.data?.sceneCreate.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// clear the cover image so that it doesn't appear dirty
|
||||
formik.resetForm({ values: formik.values });
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
|
|
@ -316,7 +353,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await queryScrapeScene(s, scene.id);
|
||||
const result = await queryScrapeScene(s, scene.id!);
|
||||
if (!result.data || !result.data.scrapeSingleScene?.length) {
|
||||
Toast.success({
|
||||
content: "No scenes found",
|
||||
|
|
@ -399,7 +436,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
|
||||
const currentScene = getSceneInput(formik.values);
|
||||
if (!currentScene.cover_image) {
|
||||
currentScene.cover_image = scene.paths.screenshot;
|
||||
currentScene.cover_image = scene.paths!.screenshot;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -670,6 +707,24 @@ export const SceneEditPanel: React.FC<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 />;
|
||||
|
||||
return (
|
||||
|
|
@ -687,25 +742,29 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
<Button
|
||||
className="edit-button"
|
||||
variant="primary"
|
||||
disabled={!formik.dirty}
|
||||
disabled={!isNew && !formik.dirty}
|
||||
onClick={() => formik.submitForm()}
|
||||
>
|
||||
<FormattedMessage id="actions.save" />
|
||||
</Button>
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="ml-auto pr-3 text-right d-flex">
|
||||
<ButtonGroup className="scraper-group">
|
||||
{renderScraperMenu()}
|
||||
{renderScrapeQueryMenu()}
|
||||
</ButtonGroup>
|
||||
{onDelete && (
|
||||
<Button
|
||||
className="edit-button"
|
||||
variant="danger"
|
||||
onClick={() => onDelete()}
|
||||
>
|
||||
<FormattedMessage id="actions.delete" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!isNew && (
|
||||
<div className="ml-auto pr-3 text-right d-flex">
|
||||
<ButtonGroup className="scraper-group">
|
||||
{renderScraperMenu()}
|
||||
{renderScrapeQueryMenu()}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-container row px-3">
|
||||
<div className="col-12 col-lg-7 col-xl-12">
|
||||
|
|
@ -758,8 +817,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
})}
|
||||
<Col sm={9}>
|
||||
<GallerySelect
|
||||
galleries={galleries}
|
||||
selected={galleries}
|
||||
onSelect={(items) => onSetGalleries(items)}
|
||||
isMulti
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
|
|
@ -918,15 +978,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
<Form.Label>
|
||||
<FormattedMessage id="cover_image" />
|
||||
</Form.Label>
|
||||
{imageEncoding ? (
|
||||
<LoadingIndicator message="Encoding image..." />
|
||||
) : (
|
||||
<img
|
||||
className="scene-cover"
|
||||
src={coverImagePreview}
|
||||
alt={intl.formatMessage({ id: "cover_image" })}
|
||||
/>
|
||||
)}
|
||||
{image}
|
||||
<ImageInput
|
||||
isEditing
|
||||
onImageChange={onCoverImageChange}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Accordion, Button, Card } from "react-bootstrap";
|
||||
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { TruncatedText } from "src/components/Shared";
|
||||
import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog";
|
||||
import ReassignFilesDialog from "src/components/Shared/ReassignFilesDialog";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { mutateSceneSetPrimaryFile } from "src/core/StashService";
|
||||
import { useToast } from "src/hooks";
|
||||
|
|
@ -10,11 +12,13 @@ import { NavUtils, TextUtils, getStashboxBase } from "src/utils";
|
|||
import { TextField, URLField } from "src/utils/field";
|
||||
|
||||
interface IFileInfoPanelProps {
|
||||
sceneID: string;
|
||||
file: GQL.VideoFileDataFragment;
|
||||
primary?: boolean;
|
||||
ofMany?: boolean;
|
||||
onSetPrimaryFile?: () => void;
|
||||
onDeleteFile?: () => void;
|
||||
onReassign?: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +26,7 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
|||
props: IFileInfoPanelProps
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
|
||||
function renderFileSize() {
|
||||
const { size, unit } = TextUtils.fileSize(props.file.size);
|
||||
|
|
@ -47,6 +52,12 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
|||
const phash = props.file.fingerprints.find((f) => f.type === "phash");
|
||||
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
|
||||
|
||||
function onSplit() {
|
||||
history.push(
|
||||
`/scenes/new?from_scene_id=${props.sceneID}&file_id=${props.file.id}`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<dl className="container scene-file-info details-list">
|
||||
|
|
@ -122,6 +133,16 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
|
|||
>
|
||||
<FormattedMessage id="actions.make_primary" />
|
||||
</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
|
||||
variant="danger"
|
||||
disabled={props.loading}
|
||||
|
|
@ -148,6 +169,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
const [deletingFile, setDeletingFile] = useState<
|
||||
GQL.VideoFileDataFragment | undefined
|
||||
>();
|
||||
const [reassigningFile, setReassigningFile] = useState<
|
||||
GQL.VideoFileDataFragment | undefined
|
||||
>();
|
||||
|
||||
function renderStashIDs() {
|
||||
if (!props.scene.stash_ids.length) {
|
||||
|
|
@ -213,7 +237,9 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -235,6 +261,12 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
selected={[deletingFile]}
|
||||
/>
|
||||
)}
|
||||
{reassigningFile && (
|
||||
<ReassignFilesDialog
|
||||
onClose={() => setReassigningFile(undefined)}
|
||||
selected={reassigningFile}
|
||||
/>
|
||||
)}
|
||||
{props.scene.files.map((file, index) => (
|
||||
<Card key={file.id} className="scene-file-card">
|
||||
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
|
||||
|
|
@ -243,11 +275,13 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
<Accordion.Collapse eventKey={file.id}>
|
||||
<Card.Body>
|
||||
<FileInfoPanel
|
||||
sceneID={props.scene.id}
|
||||
file={file}
|
||||
primary={index === 0}
|
||||
ofMany
|
||||
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
|
||||
onDeleteFile={() => setDeletingFile(file)}
|
||||
onReassign={() => setReassigningFile(file)}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card.Body>
|
||||
|
|
@ -256,7 +290,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}, [props.scene, loading, Toast, deletingFile]);
|
||||
}, [props.scene, loading, Toast, deletingFile, reassigningFile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { StudioSelect, PerformerSelect } from "src/components/Shared";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { MovieSelect, TagSelect } from "src/components/Shared/Select";
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
ScrapedInputGroupRow,
|
||||
ScrapedTextAreaRow,
|
||||
ScrapedImageRow,
|
||||
IHasName,
|
||||
} from "src/components/Shared/ScrapeDialog";
|
||||
import clone from "lodash-es/clone";
|
||||
import {
|
||||
|
|
@ -22,35 +23,45 @@ import useToast from "src/hooks/Toast";
|
|||
import DurationUtils from "src/utils/duration";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
function renderScrapedStudio(
|
||||
result: ScrapeResult<string>,
|
||||
isNew?: boolean,
|
||||
onChange?: (value: string) => void
|
||||
) {
|
||||
const resultValue = isNew ? result.newValue : result.originalValue;
|
||||
const value = resultValue ? [resultValue] : [];
|
||||
|
||||
return (
|
||||
<StudioSelect
|
||||
className="form-control react-select"
|
||||
isDisabled={!isNew}
|
||||
onSelect={(items) => {
|
||||
if (onChange) {
|
||||
onChange(items[0]?.id);
|
||||
}
|
||||
}}
|
||||
ids={value}
|
||||
/>
|
||||
);
|
||||
interface IScrapedStudioRow {
|
||||
title: string;
|
||||
result: ScrapeResult<string>;
|
||||
onChange: (value: ScrapeResult<string>) => void;
|
||||
newStudio?: GQL.ScrapedStudio;
|
||||
onCreateNew?: (value: GQL.ScrapedStudio) => void;
|
||||
}
|
||||
|
||||
function renderScrapedStudioRow(
|
||||
title: string,
|
||||
result: ScrapeResult<string>,
|
||||
onChange: (value: ScrapeResult<string>) => void,
|
||||
newStudio?: GQL.ScrapedStudio,
|
||||
onCreateNew?: (value: GQL.ScrapedStudio) => void
|
||||
) {
|
||||
export const ScrapedStudioRow: React.FC<IScrapedStudioRow> = ({
|
||||
title,
|
||||
result,
|
||||
onChange,
|
||||
newStudio,
|
||||
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 (
|
||||
<ScrapeDialogRow
|
||||
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(
|
||||
result: ScrapeResult<string[]>,
|
||||
isNew?: boolean,
|
||||
onChange?: (value: string[]) => void
|
||||
) {
|
||||
const resultValue = isNew ? result.newValue : result.originalValue;
|
||||
const value = resultValue ?? [];
|
||||
|
||||
return (
|
||||
<PerformerSelect
|
||||
isMulti
|
||||
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 };
|
||||
});
|
||||
export const ScrapedObjectsRow = <T extends IHasName>(
|
||||
props: IScrapedObjectsRow<T>
|
||||
) => {
|
||||
const {
|
||||
title,
|
||||
result,
|
||||
onChange,
|
||||
newObjects,
|
||||
onCreateNew,
|
||||
renderObjects,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ScrapeDialogRow
|
||||
title={title}
|
||||
result={result}
|
||||
renderOriginalField={() => renderScrapedPerformers(result)}
|
||||
renderOriginalField={() => renderObjects(result)}
|
||||
renderNewField={() =>
|
||||
renderScrapedPerformers(result, true, (value) =>
|
||||
renderObjects(result, true, (value) =>
|
||||
onChange(result.cloneWithValue(value))
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
newValues={performersCopy}
|
||||
newValues={newObjects}
|
||||
onCreateNew={(i) => {
|
||||
if (onCreateNew) onCreateNew(newPerformers[i]);
|
||||
if (onCreateNew) onCreateNew(newObjects![i]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function renderScrapedMovies(
|
||||
result: ScrapeResult<string[]>,
|
||||
isNew?: boolean,
|
||||
onChange?: (value: string[]) => void
|
||||
) {
|
||||
const resultValue = isNew ? result.newValue : result.originalValue;
|
||||
const value = resultValue ?? [];
|
||||
type IScrapedObjectRowImpl<T> = Omit<IScrapedObjectsRow<T>, "renderObjects">;
|
||||
|
||||
export const ScrapedPerformersRow: React.FC<
|
||||
IScrapedObjectRowImpl<GQL.ScrapedPerformer>
|
||||
> = ({ title, result, onChange, newObjects, onCreateNew }) => {
|
||||
const performersCopy = useMemo(() => {
|
||||
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 (
|
||||
<MovieSelect
|
||||
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
|
||||
<ScrapedObjectsRow<PerformerType>
|
||||
title={title}
|
||||
result={result}
|
||||
renderOriginalField={() => renderScrapedMovies(result)}
|
||||
renderNewField={() =>
|
||||
renderScrapedMovies(result, true, (value) =>
|
||||
onChange(result.cloneWithValue(value))
|
||||
)
|
||||
}
|
||||
renderObjects={renderScrapedPerformers}
|
||||
onChange={onChange}
|
||||
newValues={moviesCopy}
|
||||
onCreateNew={(i) => {
|
||||
if (onCreateNew) onCreateNew(newMovies[i]);
|
||||
}}
|
||||
newObjects={performersCopy}
|
||||
onCreateNew={onCreateNew}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function renderScrapedTags(
|
||||
result: ScrapeResult<string[]>,
|
||||
isNew?: boolean,
|
||||
onChange?: (value: string[]) => void
|
||||
) {
|
||||
const resultValue = isNew ? result.newValue : result.originalValue;
|
||||
const value = resultValue ?? [];
|
||||
export const ScrapedMoviesRow: React.FC<
|
||||
IScrapedObjectRowImpl<GQL.ScrapedMovie>
|
||||
> = ({ title, result, onChange, newObjects, onCreateNew }) => {
|
||||
const moviesCopy = useMemo(() => {
|
||||
return (
|
||||
newObjects?.map((p) => {
|
||||
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 (
|
||||
<TagSelect
|
||||
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
|
||||
<ScrapedObjectsRow<MovieType>
|
||||
title={title}
|
||||
result={result}
|
||||
renderOriginalField={() => renderScrapedTags(result)}
|
||||
renderNewField={() =>
|
||||
renderScrapedTags(result, true, (value) =>
|
||||
onChange(result.cloneWithValue(value))
|
||||
)
|
||||
}
|
||||
newValues={newTags}
|
||||
renderObjects={renderScrapedMovies}
|
||||
onChange={onChange}
|
||||
onCreateNew={(i) => {
|
||||
if (onCreateNew) onCreateNew(newTags[i]);
|
||||
}}
|
||||
newObjects={moviesCopy}
|
||||
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 {
|
||||
scene: Partial<GQL.SceneUpdateInput>;
|
||||
|
|
@ -593,34 +639,34 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
|
|||
result={director}
|
||||
onChange={(value) => setDirector(value)}
|
||||
/>
|
||||
{renderScrapedStudioRow(
|
||||
intl.formatMessage({ id: "studios" }),
|
||||
studio,
|
||||
(value) => setStudio(value),
|
||||
newStudio,
|
||||
createNewStudio
|
||||
)}
|
||||
{renderScrapedPerformersRow(
|
||||
intl.formatMessage({ id: "performers" }),
|
||||
performers,
|
||||
(value) => setPerformers(value),
|
||||
newPerformers,
|
||||
createNewPerformer
|
||||
)}
|
||||
{renderScrapedMoviesRow(
|
||||
intl.formatMessage({ id: "movies" }),
|
||||
movies,
|
||||
(value) => setMovies(value),
|
||||
newMovies,
|
||||
createNewMovie
|
||||
)}
|
||||
{renderScrapedTagsRow(
|
||||
intl.formatMessage({ id: "tags" }),
|
||||
tags,
|
||||
(value) => setTags(value),
|
||||
newTags,
|
||||
createNewTag
|
||||
)}
|
||||
<ScrapedStudioRow
|
||||
title={intl.formatMessage({ id: "studios" })}
|
||||
result={studio}
|
||||
onChange={(value) => setStudio(value)}
|
||||
newStudio={newStudio}
|
||||
onCreateNew={createNewStudio}
|
||||
/>
|
||||
<ScrapedPerformersRow
|
||||
title={intl.formatMessage({ id: "performers" })}
|
||||
result={performers}
|
||||
onChange={(value) => setPerformers(value)}
|
||||
newObjects={newPerformers}
|
||||
onCreateNew={createNewPerformer}
|
||||
/>
|
||||
<ScrapedMoviesRow
|
||||
title={intl.formatMessage({ id: "movies" })}
|
||||
result={movies}
|
||||
onChange={(value) => setMovies(value)}
|
||||
newObjects={newMovies}
|
||||
onCreateNew={createNewMovie}
|
||||
/>
|
||||
<ScrapedTagsRow
|
||||
title={intl.formatMessage({ id: "tags" })}
|
||||
result={tags}
|
||||
onChange={(value) => setTags(value)}
|
||||
newObjects={newTags}
|
||||
onCreateNew={createNewTag}
|
||||
/>
|
||||
<ScrapedTextAreaRow
|
||||
title={intl.formatMessage({ id: "details" })}
|
||||
result={details}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import { TaggerContext } from "../Tagger/context";
|
|||
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import { SceneMergeModal } from "./SceneMergeDialog";
|
||||
import { objectTitle } from "src/core/files";
|
||||
|
||||
interface ISceneList {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
|
|
@ -41,6 +43,9 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
const history = useHistory();
|
||||
const config = React.useContext(ConfigurationContext);
|
||||
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
|
||||
const [mergeScenes, setMergeScenes] = useState<
|
||||
{ id: string; title: string }[] | undefined
|
||||
>(undefined);
|
||||
const [isIdentifyDialogOpen, setIsIdentifyDialogOpen] = useState(false);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isExportAll, setIsExportAll] = useState(false);
|
||||
|
|
@ -66,6 +71,11 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
onClick: identify,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
|
||||
onClick: merge,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: onExport,
|
||||
|
|
@ -166,6 +176,24 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
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() {
|
||||
setIsExportAll(false);
|
||||
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(
|
||||
result: FindScenesQueryResult,
|
||||
filter: ListFilterModel,
|
||||
|
|
@ -293,6 +338,7 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
{maybeRenderSceneGenerateDialog(selectedIds)}
|
||||
{maybeRenderSceneIdentifyDialog(selectedIds)}
|
||||
{maybeRenderSceneExportDialog(selectedIds)}
|
||||
{renderMergeDialog()}
|
||||
{renderScenes(result, filter, selectedIds)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
662
ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx
Normal file
662
ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ import { PersistanceLevel } from "src/hooks/ListHook";
|
|||
const SceneList = lazy(() => import("./SceneList"));
|
||||
const SceneMarkerList = lazy(() => import("./SceneMarkerList"));
|
||||
const Scene = lazy(() => import("./SceneDetails/Scene"));
|
||||
const SceneCreate = lazy(() => import("./SceneDetails/SceneCreate"));
|
||||
|
||||
const Scenes: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
|
@ -30,6 +31,7 @@ const Scenes: React.FC = () => {
|
|||
)}
|
||||
/>
|
||||
<Route exact path="/scenes/markers" component={SceneMarkerList} />
|
||||
<Route exact path="/scenes/new" component={SceneCreate} />
|
||||
<Route path="/scenes/:id" component={Scene} />
|
||||
</Switch>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -240,6 +240,10 @@ textarea.scene-description {
|
|||
.scene-card.card {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
|
||||
&.fileless {
|
||||
background-color: darken($card-bg, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
.scene-cover {
|
||||
|
|
@ -476,7 +480,7 @@ input[type="range"].blue-slider {
|
|||
}
|
||||
|
||||
.rating-stars {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
|
||||
button {
|
||||
font-size: inherit;
|
||||
|
|
|
|||
101
ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx
Normal file
101
ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx
Normal 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;
|
||||
|
|
@ -29,13 +29,17 @@ export class ScrapeResult<T> {
|
|||
public scraped: boolean = false;
|
||||
public useNewValue: boolean = false;
|
||||
|
||||
public constructor(originalValue?: T | null, newValue?: T | null) {
|
||||
public constructor(
|
||||
originalValue?: T | null,
|
||||
newValue?: T | null,
|
||||
useNewValue?: boolean
|
||||
) {
|
||||
this.originalValue = originalValue ?? undefined;
|
||||
this.newValue = newValue ?? undefined;
|
||||
|
||||
const valuesEqual = isEqual(originalValue, newValue);
|
||||
this.useNewValue = !!this.newValue && !valuesEqual;
|
||||
this.scraped = this.useNewValue;
|
||||
this.useNewValue = useNewValue ?? (!!this.newValue && !valuesEqual);
|
||||
this.scraped = !!this.newValue && !valuesEqual;
|
||||
}
|
||||
|
||||
public setOriginalValue(value?: T) {
|
||||
|
|
@ -63,7 +67,12 @@ export class ScrapeResult<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;
|
||||
}
|
||||
|
||||
|
|
@ -347,6 +356,8 @@ export const ScrapedImageRow: React.FC<IScrapedImageRowProps> = (props) => {
|
|||
|
||||
interface IScrapeDialogProps {
|
||||
title: string;
|
||||
existingLabel?: string;
|
||||
scrapedLabel?: string;
|
||||
renderScrapeRows: () => JSX.Element;
|
||||
onClose: (apply?: boolean) => void;
|
||||
}
|
||||
|
|
@ -379,10 +390,14 @@ export const ScrapeDialog: React.FC<IScrapeDialogProps> = (
|
|||
<Col lg={{ span: 9, offset: 3 }}>
|
||||
<Row>
|
||||
<Form.Label column xs="6">
|
||||
<FormattedMessage id="dialogs.scrape_results_existing" />
|
||||
{props.existingLabel ?? (
|
||||
<FormattedMessage id="dialogs.scrape_results_existing" />
|
||||
)}
|
||||
</Form.Label>
|
||||
<Form.Label column xs="6">
|
||||
<FormattedMessage id="dialogs.scrape_results_scraped" />
|
||||
{props.scrapedLabel ?? (
|
||||
<FormattedMessage id="dialogs.scrape_results_scraped" />
|
||||
)}
|
||||
</Form.Label>
|
||||
</Row>
|
||||
</Col>
|
||||
|
|
|
|||
|
|
@ -96,16 +96,13 @@ interface IFilterComponentProps extends IFilterProps {
|
|||
interface IFilterSelectProps<T extends boolean>
|
||||
extends Omit<ISelectProps<T>, "onChange" | "items" | "onCreateOption"> {}
|
||||
|
||||
type Gallery = { id: string; title: string };
|
||||
interface IGallerySelect {
|
||||
galleries: Gallery[];
|
||||
onSelect: (items: Gallery[]) => void;
|
||||
}
|
||||
|
||||
type Scene = { id: string; title: string };
|
||||
interface ISceneSelect {
|
||||
scenes: Scene[];
|
||||
onSelect: (items: Scene[]) => void;
|
||||
type TitledObject = { id: string; title: string };
|
||||
interface ITitledSelect {
|
||||
className?: string;
|
||||
selected: TitledObject[];
|
||||
onSelect: (items: TitledObject[]) => void;
|
||||
isMulti?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const getSelectedItems = (selectedItems: ValueType<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 { data, loading } = GQL.useFindGalleriesQuery({
|
||||
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,
|
||||
label: g.title ?? "Unknown",
|
||||
}));
|
||||
|
||||
return (
|
||||
<SelectComponent
|
||||
className={props.className}
|
||||
onChange={onChange}
|
||||
onInputChange={onInputChange}
|
||||
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 { data, loading } = GQL.useFindScenesQuery({
|
||||
skip: query === "",
|
||||
|
|
@ -390,7 +388,7 @@ export const SceneSelect: React.FC<ISceneSelect> = (props) => {
|
|||
setQuery(input);
|
||||
}, 500);
|
||||
|
||||
const onChange = (selectedItems: ValueType<Option, true>) => {
|
||||
const onChange = (selectedItems: ValueType<Option, boolean>) => {
|
||||
const selected = getSelectedItems(selectedItems);
|
||||
props.onSelect(
|
||||
(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,
|
||||
label: s.title,
|
||||
}));
|
||||
|
|
@ -412,10 +410,63 @@ export const SceneSelect: React.FC<ISceneSelect> = (props) => {
|
|||
isLoading={loading}
|
||||
items={items}
|
||||
selectedOptions={options}
|
||||
isMulti
|
||||
isMulti={props.isMulti ?? false}
|
||||
placeholder="Search for scene..."
|
||||
noOptionsMessage={query === "" ? null : "No scenes found."}
|
||||
showDropdown={false}
|
||||
isDisabled={props.disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImageSelect: React.FC<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} />
|
||||
);
|
||||
|
||||
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 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ export function prepareQueryString(
|
|||
}
|
||||
|
||||
export const parsePath = (filePath: string) => {
|
||||
if (!filePath) {
|
||||
return {
|
||||
paths: [],
|
||||
file: "",
|
||||
ext: "",
|
||||
};
|
||||
}
|
||||
|
||||
const path = filePath.toLowerCase();
|
||||
const isWin = /^([a-z]:|\\\\)/.test(path);
|
||||
const normalizedPath = isWin
|
||||
|
|
|
|||
|
|
@ -507,6 +507,63 @@ export const mutateSceneSetPrimaryFile = (id: string, fileID: string) =>
|
|||
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 = [
|
||||
GQL.FindPerformerDocument,
|
||||
GQL.FindPerformersDocument,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
### ✨ New Features
|
||||
* Support creation of scenes without files. ([#3006](https://github.com/stashapp/stash/pull/3006))
|
||||
* Added ability to reassign files to other scenes. ([#3006](https://github.com/stashapp/stash/pull/3006))
|
||||
* Added ability to split and merge scenes. ([#3006](https://github.com/stashapp/stash/pull/3006))
|
||||
* Added Director and Studio Code fields to scenes. ([#3051](https://github.com/stashapp/stash/pull/3051))
|
||||
* Added selector for Country field. ([#1922](https://github.com/stashapp/stash/pull/1922))
|
||||
* Added tag description filter criterion. ([#3011](https://github.com/stashapp/stash/pull/3011))
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@
|
|||
"play_selected": "Play selected",
|
||||
"preview": "Preview",
|
||||
"previous_action": "Back",
|
||||
"reassign": "Reassign",
|
||||
"refresh": "Refresh",
|
||||
"reload_plugins": "Reload plugins",
|
||||
"reload_scrapers": "Reload scrapers",
|
||||
|
|
@ -98,11 +99,13 @@
|
|||
"set_image": "Set image…",
|
||||
"show": "Show",
|
||||
"show_configuration": "Show Configuration",
|
||||
"split": "Split",
|
||||
"skip": "Skip",
|
||||
"stop": "Stop",
|
||||
"submit": "Submit",
|
||||
"submit_stash_box": "Submit to Stash-Box",
|
||||
"submit_update": "Submit update",
|
||||
"swap": "Swap",
|
||||
"tasks": {
|
||||
"clean_confirm_message": "Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.",
|
||||
"dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.",
|
||||
|
|
@ -630,6 +633,7 @@
|
|||
"developmentVersion": "Development Version",
|
||||
"dialogs": {
|
||||
"aliases_must_be_unique": "aliases must be unique",
|
||||
"create_new_entity": "Create new {entity}",
|
||||
"delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
|
||||
"delete_confirm": "Are you sure you want to delete {entityName}?",
|
||||
"delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}",
|
||||
|
|
@ -665,11 +669,20 @@
|
|||
"zoom": "Zoom"
|
||||
}
|
||||
},
|
||||
"merge": {
|
||||
"destination": "Destination",
|
||||
"empty_results": "Destination field values will be unchanged.",
|
||||
"source": "Source"
|
||||
},
|
||||
"merge_tags": {
|
||||
"destination": "Destination",
|
||||
"source": "Source"
|
||||
},
|
||||
"overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?",
|
||||
"reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}",
|
||||
"reassign_files": {
|
||||
"destination": "Reassign to"
|
||||
},
|
||||
"scene_gen": {
|
||||
"force_transcodes": "Force Transcode generation",
|
||||
"force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.",
|
||||
|
|
@ -1048,8 +1061,10 @@
|
|||
"default_filter_set": "Default filter set",
|
||||
"delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
||||
"generating_screenshot": "Generating screenshot…",
|
||||
"merged_scenes": "Merged scenes",
|
||||
"merged_tags": "Merged tags",
|
||||
"rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…",
|
||||
"reassign_past_tense": "File reassigned",
|
||||
"removed_entity": "Removed {count, plural, one {{singularEntity}} other {{pluralEntity}}}",
|
||||
"saved_entity": "Saved {entity}",
|
||||
"started_auto_tagging": "Started auto tagging",
|
||||
|
|
|
|||
|
|
@ -53,8 +53,22 @@ const usePasteImage = (
|
|||
return false;
|
||||
};
|
||||
|
||||
const imageToDataURL = async (url: string) => {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
};
|
||||
|
||||
const Image = {
|
||||
onImageChange,
|
||||
usePasteImage,
|
||||
imageToDataURL,
|
||||
};
|
||||
export default Image;
|
||||
|
|
|
|||
Loading…
Reference in a new issue