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
60 changed files with 2550 additions and 412 deletions

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