diff --git a/pkg/gallery/delete.go b/pkg/gallery/delete.go index 5609b2f4b..f5186f948 100644 --- a/pkg/gallery/delete.go +++ b/pkg/gallery/delete.go @@ -22,8 +22,8 @@ func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *i imgsDestroyed = zipImgsDestroyed // only delete folder based gallery images if we're deleting the folder - if deleteFile { - folderImgsDestroyed, err := s.destroyFolderImages(ctx, i, fileDeleter, deleteGenerated, deleteFile) + if deleteFile && i.FolderID != nil { + folderImgsDestroyed, err := s.ImageService.DestroyFolderImages(ctx, *i.FolderID, fileDeleter, deleteGenerated, deleteFile) if err != nil { return nil, err } @@ -86,36 +86,3 @@ func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, f return imgsDestroyed, nil } - -func (s *Service) destroyFolderImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) { - if i.FolderID == nil { - return nil, nil - } - - var imgsDestroyed []*models.Image - - // find images in this folder - imgs, err := s.ImageFinder.FindByFolderID(ctx, *i.FolderID) - if err != nil { - return nil, err - } - - for _, img := range imgs { - if err := img.LoadGalleryIDs(ctx, s.ImageFinder); err != nil { - return nil, err - } - - // only destroy images that are not attached to other galleries - if len(img.GalleryIDs.List()) > 1 { - continue - } - - if err := s.ImageService.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile); err != nil { - return nil, err - } - - imgsDestroyed = append(imgsDestroyed, img) - } - - return imgsDestroyed, nil -} diff --git a/pkg/gallery/service.go b/pkg/gallery/service.go index a764e982c..62604e0c5 100644 --- a/pkg/gallery/service.go +++ b/pkg/gallery/service.go @@ -18,6 +18,7 @@ type ImageFinder interface { type ImageService interface { Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error) + DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) } type Service struct { diff --git a/pkg/image/delete.go b/pkg/image/delete.go index 89f4c1811..69fba9bd6 100644 --- a/pkg/image/delete.go +++ b/pkg/image/delete.go @@ -2,6 +2,7 @@ package image import ( "context" + "fmt" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" @@ -43,8 +44,9 @@ func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *Fil // Returns a slice of images that were destroyed. func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *FileDeleter, deleteGenerated bool) ([]*models.Image, error) { var imgsDestroyed []*models.Image + zipFileID := zipFile.Base().ID - imgs, err := s.Repository.FindByZipFileID(ctx, zipFile.Base().ID) + imgs, err := s.Repository.FindByZipFileID(ctx, zipFileID) if err != nil { return nil, err } @@ -54,6 +56,23 @@ func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fil return nil, err } + // #5048 - if the image has multiple files, we just want to remove the file in the zip file, + // not delete the image entirely + if len(img.Files.List()) > 1 { + for _, f := range img.Files.List() { + if f.Base().ZipFileID == nil || *f.Base().ZipFileID != zipFileID { + continue + } + + if err := s.Repository.RemoveFileID(ctx, img.ID, f.Base().ID); err != nil { + return nil, fmt.Errorf("failed to remove file from image: %w", err) + } + } + + // don't delete the image + continue + } + const deleteFileInZip = false if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip); err != nil { return nil, err @@ -65,6 +84,66 @@ func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fil return imgsDestroyed, nil } +// DestroyFolderImages destroys all images in a folder, optionally marking the files and generated files for deletion. +// It will not delete images that are attached to more than one gallery. +// Returns a slice of images that were destroyed. +func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) { + var imgsDestroyed []*models.Image + + // find images in this folder + imgs, err := s.Repository.FindByFolderID(ctx, folderID) + if err != nil { + return nil, err + } + + for _, img := range imgs { + if err := img.LoadFiles(ctx, s.Repository); err != nil { + return nil, err + } + + // #5048 - if the image has multiple files, we just want to remove the file + // in the folder + if len(img.Files.List()) > 1 { + for _, f := range img.Files.List() { + if f.Base().ParentFolderID != folderID { + continue + } + + if err := s.Repository.RemoveFileID(ctx, img.ID, f.Base().ID); err != nil { + return nil, fmt.Errorf("failed to remove file from image: %w", err) + } + + // we still want to delete the file from the folder, if applicable + if deleteFile { + if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { + return nil, fmt.Errorf("failed to delete image file: %w", err) + } + } + } + + // don't delete the image + continue + } + + if err := img.LoadGalleryIDs(ctx, s.Repository); err != nil { + return nil, err + } + + // only destroy images that are not attached to other galleries + if len(img.GalleryIDs.List()) > 1 { + continue + } + + if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile); err != nil { + return nil, err + } + + imgsDestroyed = append(imgsDestroyed, img) + } + + return imgsDestroyed, nil +} + // Destroy destroys an image, optionally marking the file and generated files for deletion. func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error { if deleteFile { diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 04fd66900..2bbf4ceeb 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -638,6 +638,20 @@ func (_m *ImageReaderWriter) QueryCount(ctx context.Context, imageFilter *models return r0, r1 } +// RemoveFileID provides a mock function with given fields: ctx, id, fileID +func (_m *ImageReaderWriter) RemoveFileID(ctx context.Context, id int, fileID models.FileID) error { + ret := _m.Called(ctx, id, fileID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok { + r0 = rf(ctx, id, fileID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // ResetOCounter provides a mock function with given fields: ctx, id func (_m *ImageReaderWriter) ResetOCounter(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index e12ae999c..954629853 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -190,27 +190,6 @@ func (_m *SceneReaderWriter) CountByFileID(ctx context.Context, fileID models.Fi return r0, r1 } -// CountByGroupID provides a mock function with given fields: ctx, groupID -func (_m *SceneReaderWriter) CountByGroupID(ctx context.Context, groupID int) (int, error) { - ret := _m.Called(ctx, groupID) - - var r0 int - if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { - r0 = rf(ctx, groupID) - } else { - r0 = ret.Get(0).(int) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, groupID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // CountByPerformerID provides a mock function with given fields: ctx, performerID func (_m *SceneReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) @@ -232,48 +211,6 @@ func (_m *SceneReaderWriter) CountByPerformerID(ctx context.Context, performerID return r0, r1 } -// CountByStudioID provides a mock function with given fields: ctx, studioID -func (_m *SceneReaderWriter) CountByStudioID(ctx context.Context, studioID int) (int, error) { - ret := _m.Called(ctx, studioID) - - var r0 int - if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { - r0 = rf(ctx, studioID) - } else { - r0 = ret.Get(0).(int) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, studioID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// CountByTagID provides a mock function with given fields: ctx, tagID -func (_m *SceneReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) { - ret := _m.Called(ctx, tagID) - - var r0 int - if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { - r0 = rf(ctx, tagID) - } else { - r0 = ret.Get(0).(int) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, tagID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // CountMissingChecksum provides a mock function with given fields: ctx func (_m *SceneReaderWriter) CountMissingChecksum(ctx context.Context) (int, error) { ret := _m.Called(ctx) diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index 1d42a84ff..1455d7762 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -89,6 +89,7 @@ type ImageWriter interface { ImageDestroyer AddFileID(ctx context.Context, id int, fileID FileID) error + RemoveFileID(ctx context.Context, id int, fileID FileID) error IncrementOCounter(ctx context.Context, id int) (int, error) DecrementOCounter(ctx context.Context, id int) (int, error) ResetOCounter(ctx context.Context, id int) (int, error) diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 8248427a8..973e45a93 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -997,6 +997,21 @@ func (qb *ImageStore) AddFileID(ctx context.Context, id int, fileID models.FileI return imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } +// RemoveFileID removes the file ID from the image. +// If the file ID is the primary file, then the next file in the list is set as the primary file. +func (qb *ImageStore) RemoveFileID(ctx context.Context, id int, fileID models.FileID) error { + fileIDs, err := imagesFilesTableMgr.get(ctx, id) + if err != nil { + return fmt.Errorf("getting file IDs for image %d: %w", id, err) + } + + fileIDs = sliceutil.Filter(fileIDs, func(f models.FileID) bool { + return f != fileID + }) + + return imagesFilesTableMgr.replaceJoins(ctx, id, fileIDs) +} + func (qb *ImageStore) GetGalleryIDs(ctx context.Context, imageID int) ([]int, error) { return imageRepository.galleries.getIDs(ctx, imageID) } diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 2ae3bf945..8d72bdcae 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -759,6 +759,29 @@ type relatedFilesTable struct { // FileID models.FileID `db:"file_id"` // } +// get returns the file IDs related to the provided scene ID +// the primary file is returned first +func (t *relatedFilesTable) get(ctx context.Context, id int) ([]models.FileID, error) { + q := dialect.Select("file_id").From(t.table.table).Where(t.idColumn.Eq(id)).Order(t.table.table.Col("primary").Desc()) + + const single = false + var ret []models.FileID + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + var v models.FileID + if err := rows.Scan(&v); err != nil { + return err + } + + ret = append(ret, v) + + return nil + }); err != nil { + return nil, fmt.Errorf("getting related files from %s: %w", t.table.table.GetTable(), err) + } + + return ret, nil +} + func (t *relatedFilesTable) insertJoin(ctx context.Context, id int, primary bool, fileID models.FileID) error { q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "primary", "file_id").Vals( goqu.Vals{id, primary, fileID},