mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34:37 +03:00
* Restructure data layer part 2 (#2599) * Refactor and separate image model * Refactor image query builder * Handle relationships in image query builder * Remove relationship management methods * Refactor gallery model/query builder * Add scenes to gallery model * Convert scene model * Refactor scene models * Remove unused methods * Add unit tests for gallery * Add image tests * Add scene tests * Convert unnecessary scene value pointers to values * Convert unnecessary pointer values to values * Refactor scene partial * Add scene partial tests * Refactor ImagePartial * Add image partial tests * Refactor gallery partial update * Add partial gallery update tests * Use zero/null package for null values * Add files and scan system * Add sqlite implementation for files/folders * Add unit tests for files/folders * Image refactors * Update image data layer * Refactor gallery model and creation * Refactor scene model * Refactor scenes * Don't set title from filename * Allow galleries to freely add/remove images * Add multiple scene file support to graphql and UI * Add multiple file support for images in graphql/UI * Add multiple file for galleries in graphql/UI * Remove use of some deprecated fields * Remove scene path usage * Remove gallery path usage * Remove path from image * Move funscript to video file * Refactor caption detection * Migrate existing data * Add post commit/rollback hook system * Lint. Comment out import/export tests * Add WithDatabase read only wrapper * Prepend tasks to list * Add 32 pre-migration * Add warnings in release and migration notes
392 lines
10 KiB
Go
392 lines
10 KiB
Go
package image
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"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/sliceutil/intslice"
|
|
)
|
|
|
|
var (
|
|
ErrNotImageFile = errors.New("not an image file")
|
|
)
|
|
|
|
// const mutexType = "image"
|
|
|
|
type FinderCreatorUpdater interface {
|
|
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Image, error)
|
|
FindByFingerprints(ctx context.Context, fp []file.Fingerprint) ([]*models.Image, error)
|
|
Create(ctx context.Context, newImage *models.ImageCreateInput) error
|
|
Update(ctx context.Context, updatedImage *models.Image) error
|
|
}
|
|
|
|
type GalleryFinderCreator interface {
|
|
FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Gallery, error)
|
|
FindByFolderID(ctx context.Context, folderID file.FolderID) ([]*models.Gallery, error)
|
|
Create(ctx context.Context, newObject *models.Gallery, fileIDs []file.ID) error
|
|
}
|
|
|
|
type ScanConfig interface {
|
|
GetCreateGalleriesFromFolders() bool
|
|
IsGenerateThumbnails() bool
|
|
}
|
|
|
|
type ScanHandler struct {
|
|
CreatorUpdater FinderCreatorUpdater
|
|
GalleryFinder GalleryFinderCreator
|
|
|
|
ThumbnailGenerator ThumbnailGenerator
|
|
|
|
ScanConfig ScanConfig
|
|
|
|
PluginCache *plugin.Cache
|
|
}
|
|
|
|
func (h *ScanHandler) validate() error {
|
|
if h.CreatorUpdater == nil {
|
|
return errors.New("CreatorUpdater is required")
|
|
}
|
|
if h.GalleryFinder == nil {
|
|
return errors.New("GalleryFinder is required")
|
|
}
|
|
if h.ScanConfig == nil {
|
|
return errors.New("ScanConfig is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *ScanHandler) Handle(ctx context.Context, f file.File) error {
|
|
if err := h.validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
imageFile, ok := f.(*file.ImageFile)
|
|
if !ok {
|
|
return ErrNotImageFile
|
|
}
|
|
|
|
// try to match the file to an image
|
|
existing, err := h.CreatorUpdater.FindByFileID(ctx, imageFile.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("finding existing image: %w", err)
|
|
}
|
|
|
|
if len(existing) == 0 {
|
|
// try also to match file by fingerprints
|
|
existing, err = h.CreatorUpdater.FindByFingerprints(ctx, imageFile.Fingerprints)
|
|
if err != nil {
|
|
return fmt.Errorf("finding existing image by fingerprints: %w", err)
|
|
}
|
|
}
|
|
|
|
if len(existing) > 0 {
|
|
if err := h.associateExisting(ctx, existing, imageFile); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// create a new image
|
|
now := time.Now()
|
|
newImage := &models.Image{
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
// if the file is in a zip, then associate it with the gallery
|
|
if imageFile.ZipFileID != nil {
|
|
g, err := h.GalleryFinder.FindByFileID(ctx, *imageFile.ZipFileID)
|
|
if err != nil {
|
|
return fmt.Errorf("finding gallery for zip file id %d: %w", *imageFile.ZipFileID, err)
|
|
}
|
|
|
|
for _, gg := range g {
|
|
newImage.GalleryIDs = append(newImage.GalleryIDs, gg.ID)
|
|
}
|
|
} else if h.ScanConfig.GetCreateGalleriesFromFolders() {
|
|
if err := h.associateFolderBasedGallery(ctx, newImage, imageFile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := h.CreatorUpdater.Create(ctx, &models.ImageCreateInput{
|
|
Image: newImage,
|
|
FileIDs: []file.ID{imageFile.ID},
|
|
}); err != nil {
|
|
return fmt.Errorf("creating new image: %w", err)
|
|
}
|
|
|
|
h.PluginCache.ExecutePostHooks(ctx, newImage.ID, plugin.ImageCreatePost, nil, nil)
|
|
|
|
existing = []*models.Image{newImage}
|
|
}
|
|
|
|
if h.ScanConfig.IsGenerateThumbnails() {
|
|
for _, s := range existing {
|
|
if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil {
|
|
// just log if cover generation fails. We can try again on rescan
|
|
logger.Errorf("Error generating thumbnail for %s: %v", imageFile.Path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Image, f *file.ImageFile) error {
|
|
for _, i := range existing {
|
|
found := false
|
|
for _, sf := range i.Files {
|
|
if sf.ID == f.Base().ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
logger.Infof("Adding %s to image %s", f.Path, i.GetTitle())
|
|
i.Files = append(i.Files, f)
|
|
|
|
// associate with folder-based gallery if applicable
|
|
if h.ScanConfig.GetCreateGalleriesFromFolders() {
|
|
if err := h.associateFolderBasedGallery(ctx, i, f); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := h.CreatorUpdater.Update(ctx, i); err != nil {
|
|
return fmt.Errorf("updating image: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f file.File) (*models.Gallery, error) {
|
|
// don't create folder-based galleries for files in zip file
|
|
if f.Base().ZipFileID != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
folderID := f.Base().ParentFolderID
|
|
g, err := h.GalleryFinder.FindByFolderID(ctx, folderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finding folder based gallery: %w", err)
|
|
}
|
|
|
|
if len(g) > 0 {
|
|
gg := g[0]
|
|
return gg, nil
|
|
}
|
|
|
|
// create a new folder-based gallery
|
|
now := time.Now()
|
|
newGallery := &models.Gallery{
|
|
FolderID: &folderID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
logger.Infof("Creating folder-based gallery for %s", filepath.Dir(f.Base().Path))
|
|
if err := h.GalleryFinder.Create(ctx, newGallery, nil); err != nil {
|
|
return nil, fmt.Errorf("creating folder based gallery: %w", err)
|
|
}
|
|
|
|
return newGallery, nil
|
|
}
|
|
|
|
func (h *ScanHandler) associateFolderBasedGallery(ctx context.Context, newImage *models.Image, f file.File) error {
|
|
g, err := h.getOrCreateFolderBasedGallery(ctx, f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if g != nil && !intslice.IntInclude(newImage.GalleryIDs, g.ID) {
|
|
newImage.GalleryIDs = append(newImage.GalleryIDs, g.ID)
|
|
logger.Infof("Adding %s to folder-based gallery %s", f.Base().Path, g.Path())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// type Scanner struct {
|
|
// file.Scanner
|
|
|
|
// StripFileExtension bool
|
|
|
|
// CaseSensitiveFs bool
|
|
// TxnManager txn.Manager
|
|
// CreatorUpdater FinderCreatorUpdater
|
|
// Paths *paths.Paths
|
|
// PluginCache *plugin.Cache
|
|
// MutexManager *utils.MutexManager
|
|
// }
|
|
|
|
// func FileScanner(hasher file.Hasher) file.Scanner {
|
|
// return file.Scanner{
|
|
// Hasher: hasher,
|
|
// CalculateMD5: true,
|
|
// }
|
|
// }
|
|
|
|
// func (scanner *Scanner) ScanExisting(ctx context.Context, existing file.FileBased, file file.SourceFile) (retImage *models.Image, err error) {
|
|
// scanned, err := scanner.Scanner.ScanExisting(existing, file)
|
|
// if err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
// i := existing.(*models.Image)
|
|
|
|
// path := scanned.New.Path
|
|
// oldChecksum := i.Checksum
|
|
// changed := false
|
|
|
|
// if scanned.ContentsChanged() {
|
|
// logger.Infof("%s has been updated: rescanning", path)
|
|
|
|
// // regenerate the file details as well
|
|
// if err := SetFileDetails(i); err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
// changed = true
|
|
// } else if scanned.FileUpdated() {
|
|
// logger.Infof("Updated image file %s", path)
|
|
|
|
// changed = true
|
|
// }
|
|
|
|
// if changed {
|
|
// i.SetFile(*scanned.New)
|
|
// i.UpdatedAt = time.Now()
|
|
|
|
// // we are operating on a checksum now, so grab a mutex on the checksum
|
|
// done := make(chan struct{})
|
|
// scanner.MutexManager.Claim(mutexType, scanned.New.Checksum, done)
|
|
|
|
// if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
|
// // free the mutex once transaction is complete
|
|
// defer close(done)
|
|
// var err error
|
|
|
|
// // ensure no clashes of hashes
|
|
// if scanned.New.Checksum != "" && scanned.Old.Checksum != scanned.New.Checksum {
|
|
// dupe, _ := scanner.CreatorUpdater.FindByChecksum(ctx, i.Checksum)
|
|
// if dupe != nil {
|
|
// return fmt.Errorf("MD5 for file %s is the same as that of %s", path, dupe.Path)
|
|
// }
|
|
// }
|
|
|
|
// err = scanner.CreatorUpdater.Update(ctx, i)
|
|
// return err
|
|
// }); err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
// retImage = i
|
|
|
|
// // remove the old thumbnail if the checksum changed - we'll regenerate it
|
|
// if oldChecksum != scanned.New.Checksum {
|
|
// // remove cache dir of gallery
|
|
// err = os.Remove(scanner.Paths.Generated.GetThumbnailPath(oldChecksum, models.DefaultGthumbWidth))
|
|
// if err != nil {
|
|
// logger.Errorf("Error deleting thumbnail image: %s", err)
|
|
// }
|
|
// }
|
|
|
|
// scanner.PluginCache.ExecutePostHooks(ctx, retImage.ID, plugin.ImageUpdatePost, nil, nil)
|
|
// }
|
|
|
|
// return
|
|
// }
|
|
|
|
// func (scanner *Scanner) ScanNew(ctx context.Context, f file.SourceFile) (retImage *models.Image, err error) {
|
|
// scanned, err := scanner.Scanner.ScanNew(f)
|
|
// if err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
// path := f.Path()
|
|
// checksum := scanned.Checksum
|
|
|
|
// // grab a mutex on the checksum
|
|
// done := make(chan struct{})
|
|
// scanner.MutexManager.Claim(mutexType, checksum, done)
|
|
// defer close(done)
|
|
|
|
// // check for image by checksum
|
|
// var existingImage *models.Image
|
|
// if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
|
// var err error
|
|
// existingImage, err = scanner.CreatorUpdater.FindByChecksum(ctx, checksum)
|
|
// return err
|
|
// }); err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
// pathDisplayName := file.ZipPathDisplayName(path)
|
|
|
|
// if existingImage != nil {
|
|
// exists := FileExists(existingImage.Path)
|
|
// if !scanner.CaseSensitiveFs {
|
|
// // #1426 - if file exists but is a case-insensitive match for the
|
|
// // original filename, then treat it as a move
|
|
// if exists && strings.EqualFold(path, existingImage.Path) {
|
|
// exists = false
|
|
// }
|
|
// }
|
|
|
|
// if exists {
|
|
// logger.Infof("%s already exists. Duplicate of %s ", pathDisplayName, file.ZipPathDisplayName(existingImage.Path))
|
|
// return nil, nil
|
|
// } else {
|
|
// logger.Infof("%s already exists. Updating path...", pathDisplayName)
|
|
|
|
// existingImage.Path = path
|
|
// if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
|
// return scanner.CreatorUpdater.Update(ctx, existingImage)
|
|
// }); err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
// retImage = existingImage
|
|
|
|
// scanner.PluginCache.ExecutePostHooks(ctx, existingImage.ID, plugin.ImageUpdatePost, nil, nil)
|
|
// }
|
|
// } else {
|
|
// logger.Infof("%s doesn't exist. Creating new item...", pathDisplayName)
|
|
// currentTime := time.Now()
|
|
// newImage := &models.Image{
|
|
// CreatedAt: currentTime,
|
|
// UpdatedAt: currentTime,
|
|
// }
|
|
// newImage.SetFile(*scanned)
|
|
// fn := GetFilename(newImage, scanner.StripFileExtension)
|
|
// newImage.Title = fn
|
|
|
|
// if err := SetFileDetails(newImage); err != nil {
|
|
// logger.Error(err.Error())
|
|
// return nil, err
|
|
// }
|
|
|
|
// if err := txn.WithTxn(ctx, scanner.TxnManager, func(ctx context.Context) error {
|
|
// return scanner.CreatorUpdater.Create(ctx, newImage)
|
|
// }); err != nil {
|
|
// return nil, err
|
|
// }
|
|
|
|
// retImage = newImage
|
|
|
|
// scanner.PluginCache.ExecutePostHooks(ctx, retImage.ID, plugin.ImageCreatePost, nil, nil)
|
|
// }
|
|
|
|
// return
|
|
// }
|