Support file-less scenes. Add scene split, merge and reassign file (#3006)

* Reassign scene file functionality
* Implement scene create
* Add scene create UI
* Add sceneMerge backend support
* Add merge scene to UI
* Populate split create with scene details
* Add merge button to duplicate checker
* Handle file-less scenes in marker preview generate
* Make unique file name for file-less scene exports
* Add o-counter to scene update input
* Hide rescan for file-less scenes
* Generate heatmap if no speed set on file
* Fix count in scene/image queries
This commit is contained in:
WithoutPants 2022-11-14 16:35:09 +11:00 committed by GitHub
parent d0b0be4dd4
commit 4a054ab081
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 2550 additions and 412 deletions

View file

@ -1,3 +1,11 @@
mutation SceneCreate(
$input: SceneCreateInput!) {
sceneCreate(input: $input) {
...SceneData
}
}
mutation SceneUpdate(
$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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -91,13 +91,15 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit
func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) {
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

View file

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

View file

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

View file

@ -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())

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"`

View file

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

View file

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

View file

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

View file

@ -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
View file

@ -0,0 +1,76 @@
package scene
import (
"context"
"errors"
"fmt"
"time"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/txn"
)
func (s *Service) Create(ctx context.Context, input *models.Scene, fileIDs []file.ID, coverImage []byte) (*models.Scene, error) {
// title must be set if no files are provided
if input.Title == "" && len(fileIDs) == 0 {
return nil, errors.New("title must be set if scene has no files")
}
now := time.Now()
newScene := *input
newScene.CreatedAt = now
newScene.UpdatedAt = now
// don't pass the file ids since they may be already assigned
// assign them afterwards
if err := s.Repository.Create(ctx, &newScene, nil); err != nil {
return nil, fmt.Errorf("creating new scene: %w", err)
}
for _, f := range fileIDs {
if err := s.AssignFile(ctx, newScene.ID, f); err != nil {
return nil, fmt.Errorf("assigning file %d to new scene: %w", f, err)
}
}
if len(fileIDs) > 0 {
// assign the primary to the first
if _, err := s.Repository.UpdatePartial(ctx, newScene.ID, models.ScenePartial{
PrimaryFileID: &fileIDs[0],
}); err != nil {
return nil, fmt.Errorf("setting primary file on new scene: %w", err)
}
}
// re-find the scene so that it correctly returns file-related fields
ret, err := s.Repository.Find(ctx, newScene.ID)
if err != nil {
return nil, err
}
if len(coverImage) > 0 {
if err := s.Repository.UpdateCover(ctx, ret.ID, coverImage); err != nil {
return nil, fmt.Errorf("setting cover on new scene: %w", err)
}
// only update the cover image if provided and everything else was successful
// only do this if there is a file associated
if len(fileIDs) > 0 {
txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
if err := SetScreenshot(s.Paths, ret.GetHash(s.Config.GetVideoFileNamingAlgorithm()), coverImage); err != nil {
logger.Errorf("Error setting screenshot: %v", err)
}
return nil
})
}
}
s.PluginCache.RegisterPostHooks(ctx, ret.ID, plugin.SceneCreatePost, nil, nil)
// re-find the scene so that it correctly returns file-related fields
return ret, nil
}

View file

@ -128,7 +128,7 @@ type MarkerDestroyer interface {
// Destroy deletes a scene and its associated relationships from the
// 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
View file

@ -0,0 +1,144 @@
package scene
import (
"context"
"errors"
"fmt"
"os"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/intslice"
"github.com/stashapp/stash/pkg/txn"
)
func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, scenePartial models.ScenePartial) error {
// ensure source ids are unique
sourceIDs = intslice.IntAppendUniques(nil, sourceIDs)
// ensure destination is not in source list
if intslice.IntInclude(sourceIDs, destinationID) {
return errors.New("destination scene cannot be in source list")
}
dest, err := s.Repository.Find(ctx, destinationID)
if err != nil {
return fmt.Errorf("finding destination scene ID %d: %w", destinationID, err)
}
sources, err := s.Repository.FindMany(ctx, sourceIDs)
if err != nil {
return fmt.Errorf("finding source scenes: %w", err)
}
var fileIDs []file.ID
for _, src := range sources {
// TODO - delete generated files as needed
if err := src.LoadRelationships(ctx, s.Repository); err != nil {
return fmt.Errorf("loading scene relationships from %d: %w", src.ID, err)
}
for _, f := range src.Files.List() {
fileIDs = append(fileIDs, f.Base().ID)
}
if err := s.mergeSceneMarkers(ctx, dest, src); err != nil {
return err
}
}
// move files to destination scene
if len(fileIDs) > 0 {
if err := s.Repository.AssignFiles(ctx, destinationID, fileIDs); err != nil {
return fmt.Errorf("moving files to destination scene: %w", err)
}
// if scene didn't already have a primary file, then set it now
if dest.PrimaryFileID == nil {
scenePartial.PrimaryFileID = &fileIDs[0]
} else {
// don't allow changing primary file ID from the input values
scenePartial.PrimaryFileID = nil
}
}
if _, err := s.Repository.UpdatePartial(ctx, destinationID, scenePartial); err != nil {
return fmt.Errorf("updating scene: %w", err)
}
// delete old scenes
for _, srcID := range sourceIDs {
if err := s.Repository.Destroy(ctx, srcID); err != nil {
return fmt.Errorf("deleting scene %d: %w", srcID, err)
}
}
return nil
}
func (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src *models.Scene) error {
markers, err := s.MarkerRepository.FindBySceneID(ctx, src.ID)
if err != nil {
return fmt.Errorf("finding scene markers: %w", err)
}
type rename struct {
src string
dest string
}
var toRename []rename
destHash := dest.GetHash(s.Config.GetVideoFileNamingAlgorithm())
for _, m := range markers {
srcHash := src.GetHash(s.Config.GetVideoFileNamingAlgorithm())
// updated the scene id
m.SceneID.Int64 = int64(dest.ID)
if _, err := s.MarkerRepository.Update(ctx, *m); err != nil {
return fmt.Errorf("updating scene marker %d: %w", m.ID, err)
}
// move generated files to new location
toRename = append(toRename, []rename{
{
src: s.Paths.SceneMarkers.GetScreenshotPath(srcHash, int(m.Seconds)),
dest: s.Paths.SceneMarkers.GetScreenshotPath(destHash, int(m.Seconds)),
},
{
src: s.Paths.SceneMarkers.GetThumbnailPath(srcHash, int(m.Seconds)),
dest: s.Paths.SceneMarkers.GetThumbnailPath(destHash, int(m.Seconds)),
},
{
src: s.Paths.SceneMarkers.GetWebpPreviewPath(srcHash, int(m.Seconds)),
dest: s.Paths.SceneMarkers.GetWebpPreviewPath(destHash, int(m.Seconds)),
},
}...)
}
if len(toRename) > 0 {
txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
// rename the files if they exist
for _, e := range toRename {
srcExists, _ := fsutil.FileExists(e.src)
destExists, _ := fsutil.FileExists(e.dest)
if srcExists && !destExists {
if err := os.Rename(e.src, e.dest); err != nil {
logger.Errorf("Error renaming generated marker file from %s to %s: %v", e.src, e.dest, err)
}
}
}
return nil
})
}
return nil
}

View file

@ -16,6 +16,7 @@ type Queryer interface {
type IDFinder interface {
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.

View file

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

View file

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

View file

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

View file

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

View file

@ -97,6 +97,10 @@ func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scen
// populate performers, studio and tags based on scene path
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)

View file

@ -189,13 +189,22 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, ids []int)
}
func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*scraper.ScrapedScene, error) {
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) {

View file

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

View file

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

View file

@ -4077,5 +4077,47 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
})
}
func TestSceneStore_AssignFiles(t *testing.T) {
tests := []struct {
name string
sceneID int
fileID file.ID
wantErr bool
}{
{
"valid",
sceneIDs[sceneIdx1WithPerformer],
sceneFileIDs[sceneIdx1WithStudio],
false,
},
{
"invalid file id",
sceneIDs[sceneIdx1WithPerformer],
invalidFileID,
true,
},
{
"invalid scene id",
invalidID,
sceneFileIDs[sceneIdx1WithStudio],
true,
},
}
qb := db.Scene
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
withRollbackTxn(func(ctx context.Context) error {
if err := qb.AssignFiles(ctx, tt.sceneID, []file.ID{tt.fileID}); (err != nil) != tt.wantErr {
t.Errorf("SceneStore.AssignFiles() error = %v, wantErr %v", err, tt.wantErr)
}
return nil
})
})
}
}
// TODO Count
// TODO SizeCount

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,74 @@
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { SceneEditPanel } from "./SceneEditPanel";
import queryString from "query-string";
import { useFindScene } from "src/core/StashService";
import { ImageUtils } from "src/utils";
import { LoadingIndicator } from "src/components/Shared";
const SceneCreate: React.FC = () => {
const intl = useIntl();
// create scene from provided scene id if applicable
const queryParams = queryString.parse(location.search);
const fromSceneID = (queryParams?.from_scene_id ?? "") as string;
const { data, loading } = useFindScene(fromSceneID ?? "");
const [loadingCoverImage, setLoadingCoverImage] = useState(false);
const [coverImage, setCoverImage] = useState<string | undefined>(undefined);
const scene = useMemo(() => {
if (data?.findScene) {
return {
...data.findScene,
paths: undefined,
id: undefined,
};
}
return {};
}, [data?.findScene]);
useEffect(() => {
async function fetchCoverImage() {
const srcScene = data?.findScene;
if (srcScene?.paths.screenshot) {
setLoadingCoverImage(true);
const imageData = await ImageUtils.imageToDataURL(
srcScene.paths.screenshot
);
setCoverImage(imageData);
setLoadingCoverImage(false);
} else {
setCoverImage(undefined);
}
}
fetchCoverImage();
}, [data?.findScene]);
if (loading || loadingCoverImage) {
return <LoadingIndicator />;
}
return (
<div className="row new-view justify-content-center" id="create-scene-page">
<div className="col-md-8">
<h2>
<FormattedMessage
id="actions.create_entity"
values={{ entityType: intl.formatMessage({ id: "scene" }) }}
/>
</h2>
<SceneEditPanel
scene={scene}
initialCoverImage={coverImage}
isVisible
isNew
/>
</div>
</div>
);
};
export default SceneCreate;

View file

@ -19,6 +19,7 @@ import {
useSceneUpdate,
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}

View file

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

View file

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { StudioSelect, PerformerSelect } from "src/components/Shared";
import * 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}

View file

@ -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)}
</>
);

View file

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

View file

@ -8,6 +8,7 @@ import { PersistanceLevel } from "src/hooks/ListHook";
const SceneList = lazy(() => import("./SceneList"));
const 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>
</>

View file

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

View file

@ -0,0 +1,101 @@
import React, { useState } from "react";
import { Modal, SceneSelect } from "src/components/Shared";
import { useToast } from "src/hooks";
import { useIntl } from "react-intl";
import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons";
import { Col, Form, Row } from "react-bootstrap";
import { FormUtils } from "src/utils";
import { mutateSceneAssignFile } from "src/core/StashService";
interface IFile {
id: string;
path: string;
}
interface IReassignFilesDialogProps {
selected: IFile;
onClose: () => void;
}
export const ReassignFilesDialog: React.FC<IReassignFilesDialogProps> = (
props: IReassignFilesDialogProps
) => {
const [scenes, setScenes] = useState<{ id: string; title: string }[]>([]);
const intl = useIntl();
const singularEntity = intl.formatMessage({ id: "file" });
const pluralEntity = intl.formatMessage({ id: "files" });
const header = intl.formatMessage(
{ id: "dialogs.reassign_entity_title" },
{ count: 1, singularEntity, pluralEntity }
);
const toastMessage = intl.formatMessage(
{ id: "toast.reassign_past_tense" },
{ count: 1, singularEntity, pluralEntity }
);
const Toast = useToast();
// Network state
const [reassigning, setReassigning] = useState(false);
async function onAccept() {
if (!scenes.length) {
return;
}
setReassigning(true);
try {
await mutateSceneAssignFile(scenes[0].id, props.selected.id);
Toast.success({ content: toastMessage });
props.onClose();
} catch (e) {
Toast.error(e);
props.onClose();
}
setReassigning(false);
}
return (
<Modal
show
icon={faSignOutAlt}
header={header}
accept={{
onClick: onAccept,
text: intl.formatMessage({ id: "actions.reassign" }),
}}
cancel={{
onClick: () => props.onClose(),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
isRunning={reassigning}
>
<Form>
<Form.Group controlId="dest" as={Row}>
{FormUtils.renderLabel({
title: intl.formatMessage({
id: "dialogs.reassign_files.destination",
}),
labelProps: {
column: true,
sm: 3,
xl: 12,
},
})}
<Col sm={9} xl={12}>
<SceneSelect
selected={scenes}
onSelect={(items) => setScenes(items)}
/>
</Col>
</Form.Group>
</Form>
</Modal>
);
};
export default ReassignFilesDialog;

View file

@ -29,13 +29,17 @@ export class ScrapeResult<T> {
public scraped: boolean = false;
public 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>

View file

@ -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 },
}}
/>
);
};

View file

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

View file

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

View file

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

View file

@ -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",

View file

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