package manager import ( "archive/zip" "context" "database/sql" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/utils" ) type ScanJob struct { txnManager models.TransactionManager input models.ScanMetadataInput subscriptions *subscriptionManager } func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) { input := j.input paths := getScanPaths(input.Paths) var total *int var newFiles *int progress.ExecuteTask("Counting files to scan...", func() { total, newFiles = j.neededScan(ctx, paths) }) if job.IsCancelled(ctx) { logger.Info("Stopping due to user request") return } if total == nil || newFiles == nil { logger.Infof("Taking too long to count content. Skipping...") logger.Infof("Starting scan") } else { logger.Infof("Starting scan of %d files. %d New files found", *total, *newFiles) } start := time.Now() config := config.GetInstance() parallelTasks := config.GetParallelTasksWithAutoDetection() logger.Infof("Scan started with %d parallel tasks", parallelTasks) wg := sizedwaitgroup.New(parallelTasks) if total != nil { progress.SetTotal(*total) } fileNamingAlgo := config.GetVideoFileNamingAlgorithm() calculateMD5 := config.IsCalculateMD5() stoppingErr := errors.New("stopping") var err error var galleries []string for _, sp := range paths { csFs, er := utils.IsFsPathCaseSensitive(sp.Path) if er != nil { logger.Warnf("Cannot determine fs case sensitivity: %s", er.Error()) } err = walkFilesToScan(sp, func(path string, info os.FileInfo, err error) error { if job.IsCancelled(ctx) { return stoppingErr } if isGallery(path) { galleries = append(galleries, path) } if err := instance.Paths.Generated.EnsureTmpDir(); err != nil { logger.Warnf("couldn't create temporary directory: %v", err) } wg.Add() task := ScanTask{ TxnManager: j.txnManager, FilePath: path, UseFileMetadata: utils.IsTrue(input.UseFileMetadata), StripFileExtension: utils.IsTrue(input.StripFileExtension), fileNamingAlgorithm: fileNamingAlgo, calculateMD5: calculateMD5, GeneratePreview: utils.IsTrue(input.ScanGeneratePreviews), GenerateImagePreview: utils.IsTrue(input.ScanGenerateImagePreviews), GenerateSprite: utils.IsTrue(input.ScanGenerateSprites), GeneratePhash: utils.IsTrue(input.ScanGeneratePhashes), progress: progress, CaseSensitiveFs: csFs, ctx: ctx, } go func() { task.Start(&wg) progress.Increment() }() return nil }) if err == stoppingErr { logger.Info("Stopping due to user request") break } if err != nil { logger.Errorf("Error encountered scanning files: %s", err.Error()) break } } wg.Wait() if err := instance.Paths.Generated.EmptyTmpDir(); err != nil { logger.Warnf("couldn't empty temporary directory: %v", err) } elapsed := time.Since(start) logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed)) if job.IsCancelled(ctx) || err != nil { return } progress.ExecuteTask("Associating galleries", func() { for _, path := range galleries { wg.Add() task := ScanTask{ TxnManager: j.txnManager, FilePath: path, UseFileMetadata: false, } go task.associateGallery(&wg) wg.Wait() } logger.Info("Finished gallery association") }) j.subscriptions.notify() } func (j *ScanJob) neededScan(ctx context.Context, paths []*models.StashConfig) (total *int, newFiles *int) { const timeout = 90 * time.Second // create a control channel through which to signal the counting loop when the timeout is reached chTimeout := time.After(timeout) logger.Infof("Counting files to scan...") t := 0 n := 0 timeoutErr := errors.New("timed out") for _, sp := range paths { err := walkFilesToScan(sp, func(path string, info os.FileInfo, err error) error { t++ task := ScanTask{FilePath: path, TxnManager: j.txnManager} if !task.doesPathExist() { n++ } //check for timeout select { case <-chTimeout: return timeoutErr default: } // check stop if job.IsCancelled(ctx) { return timeoutErr } return nil }) if err == timeoutErr { // timeout should return nil counts return nil, nil } if err != nil { logger.Errorf("Error encountered counting files to scan: %s", err.Error()) return nil, nil } } return &t, &n } type ScanTask struct { ctx context.Context TxnManager models.TransactionManager FilePath string UseFileMetadata bool StripFileExtension bool calculateMD5 bool fileNamingAlgorithm models.HashAlgorithm GenerateSprite bool GeneratePhash bool GeneratePreview bool GenerateImagePreview bool zipGallery *models.Gallery progress *job.Progress CaseSensitiveFs bool } func (t *ScanTask) Start(wg *sizedwaitgroup.SizedWaitGroup) { defer wg.Done() var s *models.Scene t.progress.ExecuteTask("Scanning "+t.FilePath, func() { if isGallery(t.FilePath) { t.scanGallery() } else if isVideo(t.FilePath) { s = t.scanScene() } else if isImage(t.FilePath) { t.scanImage() } }) if s != nil { iwg := sizedwaitgroup.New(2) if t.GenerateSprite { iwg.Add() go t.progress.ExecuteTask(fmt.Sprintf("Generating sprites for %s", t.FilePath), func() { taskSprite := GenerateSpriteTask{ Scene: *s, Overwrite: false, fileNamingAlgorithm: t.fileNamingAlgorithm, } taskSprite.Start(&iwg) }) } if t.GeneratePhash { iwg.Add() go t.progress.ExecuteTask(fmt.Sprintf("Generating phash for %s", t.FilePath), func() { taskPhash := GeneratePhashTask{ Scene: *s, fileNamingAlgorithm: t.fileNamingAlgorithm, txnManager: t.TxnManager, } taskPhash.Start(&iwg) }) } if t.GeneratePreview { iwg.Add() go t.progress.ExecuteTask(fmt.Sprintf("Generating preview for %s", t.FilePath), func() { config := config.GetInstance() var previewSegmentDuration = config.GetPreviewSegmentDuration() var previewSegments = config.GetPreviewSegments() var previewExcludeStart = config.GetPreviewExcludeStart() var previewExcludeEnd = config.GetPreviewExcludeEnd() var previewPresent = config.GetPreviewPreset() // NOTE: the reuse of this model like this is painful. previewOptions := models.GeneratePreviewOptionsInput{ PreviewSegments: &previewSegments, PreviewSegmentDuration: &previewSegmentDuration, PreviewExcludeStart: &previewExcludeStart, PreviewExcludeEnd: &previewExcludeEnd, PreviewPreset: &previewPresent, } taskPreview := GeneratePreviewTask{ Scene: *s, ImagePreview: t.GenerateImagePreview, Options: previewOptions, Overwrite: false, fileNamingAlgorithm: t.fileNamingAlgorithm, } taskPreview.Start(wg) }) } iwg.Wait() } } func (t *ScanTask) scanGallery() { var g *models.Gallery images := 0 scanImages := false if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { var err error g, err = r.Gallery().FindByPath(t.FilePath) if g != nil && err != nil { images, err = r.Image().CountByGalleryID(g.ID) if err != nil { return fmt.Errorf("error getting images for zip gallery %s: %s", t.FilePath, err.Error()) } } return err }); err != nil { logger.Error(err.Error()) return } fileModTime, err := t.getFileModTime() if err != nil { logger.Error(err.Error()) return } if g != nil { // We already have this item in the database, keep going // if file mod time is not set, set it now if !g.FileModTime.Valid { // we will also need to rescan the zip contents scanImages = true logger.Infof("setting file modification time on %s", t.FilePath) if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { qb := r.Gallery() if _, err := gallery.UpdateFileModTime(qb, g.ID, models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }); err != nil { return err } // update our copy of the gallery var err error g, err = qb.Find(g.ID) return err }); err != nil { logger.Error(err.Error()) return } } // if the mod time of the zip file is different than that of the associated // gallery, then recalculate the checksum modified := t.isFileModified(fileModTime, g.FileModTime) if modified { scanImages = true logger.Infof("%s has been updated: rescanning", t.FilePath) // update the checksum and the modification time checksum, err := t.calculateChecksum() if err != nil { logger.Error(err.Error()) return } currentTime := time.Now() galleryPartial := models.GalleryPartial{ ID: g.ID, Checksum: &checksum, FileModTime: &models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }, UpdatedAt: &models.SQLiteTimestamp{Timestamp: currentTime}, } if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { _, err := r.Gallery().UpdatePartial(galleryPartial) return err }); err != nil { logger.Error(err.Error()) return } } // scan the zip files if the gallery has no images scanImages = scanImages || images == 0 } else { // Ignore directories. if isDir, _ := utils.DirExists(t.FilePath); isDir { return } checksum, err := t.calculateChecksum() if err != nil { logger.Error(err.Error()) return } isNewGallery := false if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { qb := r.Gallery() g, _ = qb.FindByChecksum(checksum) if g != nil { exists, _ := utils.FileExists(g.Path.String) if !t.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(t.FilePath, g.Path.String) { exists = false } } if exists { logger.Infof("%s already exists. Duplicate of %s ", t.FilePath, g.Path.String) } else { logger.Infof("%s already exists. Updating path...", t.FilePath) g.Path = sql.NullString{ String: t.FilePath, Valid: true, } g, err = qb.Update(*g) if err != nil { return err } GetInstance().PluginCache.ExecutePostHooks(t.ctx, g.ID, plugin.GalleryUpdatePost, nil, nil) } } else { currentTime := time.Now() newGallery := models.Gallery{ Checksum: checksum, Zip: true, Path: sql.NullString{ String: t.FilePath, Valid: true, }, FileModTime: models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }, Title: sql.NullString{ String: utils.GetNameFromPath(t.FilePath, t.StripFileExtension), Valid: true, }, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, } // don't create gallery if it has no images if countImagesInZip(t.FilePath) > 0 { // only warn when creating the gallery ok, err := utils.IsZipFileUncompressed(t.FilePath) if err == nil && !ok { logger.Warnf("%s is using above store (0) level compression.", t.FilePath) } logger.Infof("%s doesn't exist. Creating new item...", t.FilePath) g, err = qb.Create(newGallery) if err != nil { return err } scanImages = true isNewGallery = true } } return nil }); err != nil { logger.Error(err.Error()) return } if isNewGallery { GetInstance().PluginCache.ExecutePostHooks(t.ctx, g.ID, plugin.GalleryCreatePost, nil, nil) } } if g != nil { if scanImages { t.scanZipImages(g) } else { // in case thumbnails have been deleted, regenerate them t.regenerateZipImages(g) } } } func (t *ScanTask) getFileModTime() (time.Time, error) { fi, err := os.Stat(t.FilePath) if err != nil { return time.Time{}, fmt.Errorf("error performing stat on %s: %s", t.FilePath, err.Error()) } ret := fi.ModTime() // truncate to seconds, since we don't store beyond that in the database ret = ret.Truncate(time.Second) return ret, nil } func (t *ScanTask) getInteractive() bool { _, err := os.Stat(utils.GetFunscriptPath(t.FilePath)) return err == nil } func (t *ScanTask) isFileModified(fileModTime time.Time, modTime models.NullSQLiteTimestamp) bool { return !modTime.Timestamp.Equal(fileModTime) } // associates a gallery to a scene with the same basename func (t *ScanTask) associateGallery(wg *sizedwaitgroup.SizedWaitGroup) { if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { qb := r.Gallery() sqb := r.Scene() g, err := qb.FindByPath(t.FilePath) if err != nil { return err } if g == nil { // associate is run after scan is finished // should only happen if gallery is a directory or an io error occurs during hashing logger.Warnf("associate: gallery %s not found in DB", t.FilePath) return nil } basename := strings.TrimSuffix(t.FilePath, filepath.Ext(t.FilePath)) var relatedFiles []string vExt := config.GetInstance().GetVideoExtensions() // make a list of media files that can be related to the gallery for _, ext := range vExt { related := basename + "." + ext // exclude gallery extensions from the related files if !isGallery(related) { relatedFiles = append(relatedFiles, related) } } for _, scenePath := range relatedFiles { scene, _ := sqb.FindByPath(scenePath) // found related Scene if scene != nil { sceneGalleries, _ := sqb.FindByGalleryID(g.ID) // check if gallery is already associated to the scene isAssoc := false for _, sg := range sceneGalleries { if scene.ID == sg.ID { isAssoc = true break } } if !isAssoc { logger.Infof("associate: Gallery %s is related to scene: %d", t.FilePath, scene.ID) if err := sqb.UpdateGalleries(scene.ID, []int{g.ID}); err != nil { return err } } } } return nil }); err != nil { logger.Error(err.Error()) } wg.Done() } func (t *ScanTask) scanScene() *models.Scene { logError := func(err error) *models.Scene { logger.Error(err.Error()) return nil } var retScene *models.Scene var s *models.Scene if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { var err error s, err = r.Scene().FindByPath(t.FilePath) return err }); err != nil { logger.Error(err.Error()) return nil } fileModTime, err := t.getFileModTime() if err != nil { return logError(err) } interactive := t.getInteractive() if s != nil { // if file mod time is not set, set it now if !s.FileModTime.Valid { logger.Infof("setting file modification time on %s", t.FilePath) if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { qb := r.Scene() if _, err := scene.UpdateFileModTime(qb, s.ID, models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }); err != nil { return err } // update our copy of the scene var err error s, err = qb.Find(s.ID) return err }); err != nil { return logError(err) } } // if the mod time of the file is different than that of the associated // scene, then recalculate the checksum and regenerate the thumbnail modified := t.isFileModified(fileModTime, s.FileModTime) config := config.GetInstance() if modified || !s.Size.Valid { oldHash := s.GetHash(config.GetVideoFileNamingAlgorithm()) s, err = t.rescanScene(s, fileModTime) if err != nil { return logError(err) } // Migrate any generated files if the hash has changed newHash := s.GetHash(config.GetVideoFileNamingAlgorithm()) if newHash != oldHash { MigrateHash(oldHash, newHash) } } // We already have this item in the database // check for thumbnails,screenshots t.makeScreenshots(nil, s.GetHash(t.fileNamingAlgorithm)) // check for container if !s.Format.Valid { videoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, t.FilePath, t.StripFileExtension) if err != nil { return logError(err) } container := ffmpeg.MatchContainer(videoFile.Container, t.FilePath) logger.Infof("Adding container %s to file %s", container, t.FilePath) if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { _, err := scene.UpdateFormat(r.Scene(), s.ID, string(container)) return err }); err != nil { return logError(err) } } // check if oshash is set if !s.OSHash.Valid { logger.Infof("Calculating oshash for existing file %s ...", t.FilePath) oshash, err := utils.OSHashFromFilePath(t.FilePath) if err != nil { return nil } if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { qb := r.Scene() // check if oshash clashes with existing scene dupe, _ := qb.FindByOSHash(oshash) if dupe != nil { return fmt.Errorf("OSHash for file %s is the same as that of %s", t.FilePath, dupe.Path) } _, err := scene.UpdateOSHash(qb, s.ID, oshash) return err }); err != nil { return logError(err) } } // check if MD5 is set, if calculateMD5 is true if t.calculateMD5 && !s.Checksum.Valid { checksum, err := t.calculateChecksum() if err != nil { return logError(err) } if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { qb := r.Scene() // check if checksum clashes with existing scene dupe, _ := qb.FindByChecksum(checksum) if dupe != nil { return fmt.Errorf("MD5 for file %s is the same as that of %s", t.FilePath, dupe.Path) } _, err := scene.UpdateChecksum(qb, s.ID, checksum) return err }); err != nil { return logError(err) } } if s.Interactive != interactive { if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { qb := r.Scene() scenePartial := models.ScenePartial{ ID: s.ID, Interactive: &interactive, } _, err := qb.Update(scenePartial) return err }); err != nil { return logError(err) } } return nil } // Ignore directories. if isDir, _ := utils.DirExists(t.FilePath); isDir { return nil } videoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, t.FilePath, t.StripFileExtension) if err != nil { logger.Error(err.Error()) return nil } container := ffmpeg.MatchContainer(videoFile.Container, t.FilePath) // Override title to be filename if UseFileMetadata is false if !t.UseFileMetadata { videoFile.SetTitleFromPath(t.StripFileExtension) } var checksum string logger.Infof("%s not found. Calculating oshash...", t.FilePath) oshash, err := utils.OSHashFromFilePath(t.FilePath) if err != nil { return logError(err) } if t.fileNamingAlgorithm == models.HashAlgorithmMd5 || t.calculateMD5 { checksum, err = t.calculateChecksum() if err != nil { return logError(err) } } // check for scene by checksum and oshash - MD5 should be // redundant, but check both txnErr := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { qb := r.Scene() if checksum != "" { s, _ = qb.FindByChecksum(checksum) } if s == nil { s, _ = qb.FindByOSHash(oshash) } return nil }) if txnErr != nil { logger.Warnf("error in read transaction: %v", txnErr) } sceneHash := oshash if t.fileNamingAlgorithm == models.HashAlgorithmMd5 { sceneHash = checksum } t.makeScreenshots(videoFile, sceneHash) if s != nil { exists, _ := utils.FileExists(s.Path) if !t.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(t.FilePath, s.Path) { exists = false } } if exists { logger.Infof("%s already exists. Duplicate of %s", t.FilePath, s.Path) } else { logger.Infof("%s already exists. Updating path...", t.FilePath) scenePartial := models.ScenePartial{ ID: s.ID, Path: &t.FilePath, Interactive: &interactive, } if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { _, err := r.Scene().Update(scenePartial) return err }); err != nil { return logError(err) } GetInstance().PluginCache.ExecutePostHooks(t.ctx, s.ID, plugin.SceneUpdatePost, nil, nil) } } else { logger.Infof("%s doesn't exist. Creating new item...", t.FilePath) currentTime := time.Now() newScene := models.Scene{ Checksum: sql.NullString{String: checksum, Valid: checksum != ""}, OSHash: sql.NullString{String: oshash, Valid: oshash != ""}, Path: t.FilePath, Title: sql.NullString{String: videoFile.Title, Valid: true}, Duration: sql.NullFloat64{Float64: videoFile.Duration, Valid: true}, VideoCodec: sql.NullString{String: videoFile.VideoCodec, Valid: true}, AudioCodec: sql.NullString{String: videoFile.AudioCodec, Valid: true}, Format: sql.NullString{String: string(container), Valid: true}, Width: sql.NullInt64{Int64: int64(videoFile.Width), Valid: true}, Height: sql.NullInt64{Int64: int64(videoFile.Height), Valid: true}, Framerate: sql.NullFloat64{Float64: videoFile.FrameRate, Valid: true}, Bitrate: sql.NullInt64{Int64: videoFile.Bitrate, Valid: true}, Size: sql.NullString{String: strconv.FormatInt(videoFile.Size, 10), Valid: true}, FileModTime: models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, Interactive: interactive, } if t.UseFileMetadata { newScene.Details = sql.NullString{String: videoFile.Comment, Valid: true} newScene.Date = models.SQLiteDate{String: videoFile.CreationTime.Format("2006-01-02")} } if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { var err error retScene, err = r.Scene().Create(newScene) return err }); err != nil { return logError(err) } GetInstance().PluginCache.ExecutePostHooks(t.ctx, retScene.ID, plugin.SceneCreatePost, nil, nil) } return retScene } func (t *ScanTask) rescanScene(s *models.Scene, fileModTime time.Time) (*models.Scene, error) { logger.Infof("%s has been updated: rescanning", t.FilePath) // update the oshash/checksum and the modification time logger.Infof("Calculating oshash for existing file %s ...", t.FilePath) oshash, err := utils.OSHashFromFilePath(t.FilePath) if err != nil { return nil, err } var checksum *sql.NullString if t.calculateMD5 { cs, err := t.calculateChecksum() if err != nil { return nil, err } checksum = &sql.NullString{ String: cs, Valid: true, } } // regenerate the file details as well videoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, t.FilePath, t.StripFileExtension) if err != nil { return nil, err } container := ffmpeg.MatchContainer(videoFile.Container, t.FilePath) currentTime := time.Now() scenePartial := models.ScenePartial{ ID: s.ID, Checksum: checksum, OSHash: &sql.NullString{ String: oshash, Valid: true, }, Duration: &sql.NullFloat64{Float64: videoFile.Duration, Valid: true}, VideoCodec: &sql.NullString{String: videoFile.VideoCodec, Valid: true}, AudioCodec: &sql.NullString{String: videoFile.AudioCodec, Valid: true}, Format: &sql.NullString{String: string(container), Valid: true}, Width: &sql.NullInt64{Int64: int64(videoFile.Width), Valid: true}, Height: &sql.NullInt64{Int64: int64(videoFile.Height), Valid: true}, Framerate: &sql.NullFloat64{Float64: videoFile.FrameRate, Valid: true}, Bitrate: &sql.NullInt64{Int64: videoFile.Bitrate, Valid: true}, Size: &sql.NullString{String: strconv.FormatInt(videoFile.Size, 10), Valid: true}, FileModTime: &models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }, UpdatedAt: &models.SQLiteTimestamp{Timestamp: currentTime}, } var ret *models.Scene if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { var err error ret, err = r.Scene().Update(scenePartial) return err }); err != nil { logger.Error(err.Error()) return nil, err } GetInstance().PluginCache.ExecutePostHooks(t.ctx, ret.ID, plugin.SceneUpdatePost, nil, nil) // leave the generated files as is - the scene file may have been moved // elsewhere return ret, nil } func (t *ScanTask) makeScreenshots(probeResult *ffmpeg.VideoFile, checksum string) { thumbPath := instance.Paths.Scene.GetThumbnailScreenshotPath(checksum) normalPath := instance.Paths.Scene.GetScreenshotPath(checksum) thumbExists, _ := utils.FileExists(thumbPath) normalExists, _ := utils.FileExists(normalPath) if thumbExists && normalExists { return } if probeResult == nil { var err error probeResult, err = ffmpeg.NewVideoFile(instance.FFProbePath, t.FilePath, t.StripFileExtension) if err != nil { logger.Error(err.Error()) return } logger.Infof("Regenerating images for %s", t.FilePath) } at := float64(probeResult.Duration) * 0.2 if !thumbExists { logger.Debugf("Creating thumbnail for %s", t.FilePath) makeScreenshot(*probeResult, thumbPath, 5, 320, at) } if !normalExists { logger.Debugf("Creating screenshot for %s", t.FilePath) makeScreenshot(*probeResult, normalPath, 2, probeResult.Width, at) } } func (t *ScanTask) scanZipImages(zipGallery *models.Gallery) { err := walkGalleryZip(zipGallery.Path.String, func(file *zip.File) error { // copy this task and change the filename subTask := *t // filepath is the zip file and the internal file name, separated by a null byte subTask.FilePath = image.ZipFilename(zipGallery.Path.String, file.Name) subTask.zipGallery = zipGallery // run the subtask and wait for it to complete iwg := sizedwaitgroup.New(1) iwg.Add() subTask.Start(&iwg) return nil }) if err != nil { logger.Warnf("failed to scan zip file images for %s: %s", zipGallery.Path.String, err.Error()) } } func (t *ScanTask) regenerateZipImages(zipGallery *models.Gallery) { var images []*models.Image if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { iqb := r.Image() var err error images, err = iqb.FindByGalleryID(zipGallery.ID) return err }); err != nil { logger.Warnf("failed to find gallery images: %s", err.Error()) return } for _, img := range images { t.generateThumbnail(img) } } func (t *ScanTask) scanImage() { var i *models.Image if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { var err error i, err = r.Image().FindByPath(t.FilePath) return err }); err != nil { logger.Error(err.Error()) return } fileModTime, err := image.GetFileModTime(t.FilePath) if err != nil { logger.Error(err.Error()) return } if i != nil { // if file mod time is not set, set it now if !i.FileModTime.Valid { logger.Infof("setting file modification time on %s", t.FilePath) if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { qb := r.Image() if _, err := image.UpdateFileModTime(qb, i.ID, models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }); err != nil { return err } // update our copy of the gallery var err error i, err = qb.Find(i.ID) return err }); err != nil { logger.Error(err.Error()) return } } // if the mod time of the file is different than that of the associated // image, then recalculate the checksum and regenerate the thumbnail modified := t.isFileModified(fileModTime, i.FileModTime) if modified { i, err = t.rescanImage(i, fileModTime) if err != nil { logger.Error(err.Error()) return } } // We already have this item in the database // check for thumbnails t.generateThumbnail(i) } else { // Ignore directories. if isDir, _ := utils.DirExists(t.FilePath); isDir { return } var checksum string logger.Infof("%s not found. Calculating checksum...", t.FilePath) checksum, err = t.calculateImageChecksum() if err != nil { logger.Errorf("error calculating checksum for %s: %s", t.FilePath, err.Error()) return } // check for scene by checksum and oshash - MD5 should be // redundant, but check both if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { var err error i, err = r.Image().FindByChecksum(checksum) return err }); err != nil { logger.Error(err.Error()) return } if i != nil { exists := image.FileExists(i.Path) if !t.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(t.FilePath, i.Path) { exists = false } } if exists { logger.Infof("%s already exists. Duplicate of %s ", image.PathDisplayName(t.FilePath), image.PathDisplayName(i.Path)) } else { logger.Infof("%s already exists. Updating path...", image.PathDisplayName(t.FilePath)) imagePartial := models.ImagePartial{ ID: i.ID, Path: &t.FilePath, } if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { _, err := r.Image().Update(imagePartial) return err }); err != nil { logger.Error(err.Error()) return } GetInstance().PluginCache.ExecutePostHooks(t.ctx, i.ID, plugin.ImageUpdatePost, nil, nil) } } else { logger.Infof("%s doesn't exist. Creating new item...", image.PathDisplayName(t.FilePath)) currentTime := time.Now() newImage := models.Image{ Checksum: checksum, Path: t.FilePath, FileModTime: models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, } newImage.Title.String = image.GetFilename(&newImage, t.StripFileExtension) newImage.Title.Valid = true if err := image.SetFileDetails(&newImage); err != nil { logger.Error(err.Error()) return } if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { var err error i, err = r.Image().Create(newImage) return err }); err != nil { logger.Error(err.Error()) return } GetInstance().PluginCache.ExecutePostHooks(t.ctx, i.ID, plugin.ImageCreatePost, nil, nil) } if t.zipGallery != nil { // associate with gallery if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { return gallery.AddImage(r.Gallery(), t.zipGallery.ID, i.ID) }); err != nil { logger.Error(err.Error()) return } } else if config.GetInstance().GetCreateGalleriesFromFolders() { // create gallery from folder or associate with existing gallery logger.Infof("Associating image %s with folder gallery", i.Path) var galleryID int var isNewGallery bool if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { var err error galleryID, isNewGallery, err = t.associateImageWithFolderGallery(i.ID, r.Gallery()) return err }); err != nil { logger.Error(err.Error()) return } if isNewGallery { GetInstance().PluginCache.ExecutePostHooks(t.ctx, galleryID, plugin.GalleryCreatePost, nil, nil) } } } if i != nil { t.generateThumbnail(i) } } func (t *ScanTask) rescanImage(i *models.Image, fileModTime time.Time) (*models.Image, error) { logger.Infof("%s has been updated: rescanning", t.FilePath) oldChecksum := i.Checksum // update the checksum and the modification time checksum, err := t.calculateImageChecksum() if err != nil { return nil, err } // regenerate the file details as well fileDetails, err := image.GetFileDetails(t.FilePath) if err != nil { return nil, err } currentTime := time.Now() imagePartial := models.ImagePartial{ ID: i.ID, Checksum: &checksum, Width: &fileDetails.Width, Height: &fileDetails.Height, Size: &fileDetails.Size, FileModTime: &models.NullSQLiteTimestamp{ Timestamp: fileModTime, Valid: true, }, UpdatedAt: &models.SQLiteTimestamp{Timestamp: currentTime}, } var ret *models.Image if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { var err error ret, err = r.Image().Update(imagePartial) return err }); err != nil { return nil, err } // remove the old thumbnail if the checksum changed - we'll regenerate it if oldChecksum != checksum { err = os.Remove(GetInstance().Paths.Generated.GetThumbnailPath(oldChecksum, models.DefaultGthumbWidth)) // remove cache dir of gallery if err != nil { logger.Errorf("Error deleting thumbnail image: %s", err) } } GetInstance().PluginCache.ExecutePostHooks(t.ctx, ret.ID, plugin.ImageUpdatePost, nil, nil) return ret, nil } func (t *ScanTask) associateImageWithFolderGallery(imageID int, qb models.GalleryReaderWriter) (galleryID int, isNew bool, err error) { // find a gallery with the path specified path := filepath.Dir(t.FilePath) var g *models.Gallery g, err = qb.FindByPath(path) if err != nil { return } if g == nil { checksum := utils.MD5FromString(path) // create the gallery currentTime := time.Now() newGallery := models.Gallery{ Checksum: checksum, Path: sql.NullString{ String: path, Valid: true, }, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, Title: sql.NullString{ String: utils.GetNameFromPath(path, false), Valid: true, }, } logger.Infof("Creating gallery for folder %s", path) g, err = qb.Create(newGallery) if err != nil { return 0, false, err } isNew = true } // associate image with gallery err = gallery.AddImage(qb, g.ID, imageID) galleryID = g.ID return } func (t *ScanTask) generateThumbnail(i *models.Image) { thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth) exists, _ := utils.FileExists(thumbPath) if exists { return } srcImage, err := image.GetSourceImage(i) if err != nil { logger.Errorf("error reading image %s: %s", i.Path, err.Error()) return } if image.ThumbnailNeeded(srcImage, models.DefaultGthumbWidth) { data, err := image.GetThumbnail(srcImage, models.DefaultGthumbWidth) if err != nil { logger.Errorf("error getting thumbnail for image %s: %s", i.Path, err.Error()) return } err = utils.WriteFile(thumbPath, data) if err != nil { logger.Errorf("error writing thumbnail for image %s: %s", i.Path, err) } } } func (t *ScanTask) calculateChecksum() (string, error) { logger.Infof("Calculating checksum for %s...", t.FilePath) checksum, err := utils.MD5FromFilePath(t.FilePath) if err != nil { return "", err } logger.Debugf("Checksum calculated: %s", checksum) return checksum, nil } func (t *ScanTask) calculateImageChecksum() (string, error) { logger.Infof("Calculating checksum for %s...", image.PathDisplayName(t.FilePath)) // uses image.CalculateMD5 to read files in zips checksum, err := image.CalculateMD5(t.FilePath) if err != nil { return "", err } logger.Debugf("Checksum calculated: %s", checksum) return checksum, nil } func (t *ScanTask) doesPathExist() bool { config := config.GetInstance() vidExt := config.GetVideoExtensions() imgExt := config.GetImageExtensions() gExt := config.GetGalleryExtensions() ret := false txnErr := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { if matchExtension(t.FilePath, gExt) { gallery, _ := r.Gallery().FindByPath(t.FilePath) if gallery != nil { ret = true } } else if matchExtension(t.FilePath, vidExt) { s, _ := r.Scene().FindByPath(t.FilePath) if s != nil { ret = true } } else if matchExtension(t.FilePath, imgExt) { i, _ := r.Image().FindByPath(t.FilePath) if i != nil { ret = true } } return nil }) if txnErr != nil { logger.Warnf("error while executing read transaction: %v", txnErr) } return ret } func walkFilesToScan(s *models.StashConfig, f filepath.WalkFunc) error { config := config.GetInstance() vidExt := config.GetVideoExtensions() imgExt := config.GetImageExtensions() gExt := config.GetGalleryExtensions() excludeVidRegex := generateRegexps(config.GetExcludes()) excludeImgRegex := generateRegexps(config.GetImageExcludes()) // don't scan zip images directly if image.IsZipPath(s.Path) { logger.Warnf("Cannot rescan zip image %s. Rescan zip gallery instead.", s.Path) return nil } generatedPath := config.GetGeneratedPath() return utils.SymWalk(s.Path, func(path string, info os.FileInfo, err error) error { if err != nil { logger.Warnf("error scanning %s: %s", path, err.Error()) return nil } if info.IsDir() { // #1102 - ignore files in generated path if utils.IsPathInDir(generatedPath, path) { return filepath.SkipDir } // shortcut: skip the directory entirely if it matches both exclusion patterns // add a trailing separator so that it correctly matches against patterns like path/.* pathExcludeTest := path + string(filepath.Separator) if (s.ExcludeVideo || matchFileRegex(pathExcludeTest, excludeVidRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, excludeImgRegex)) { return filepath.SkipDir } return nil } if !s.ExcludeVideo && matchExtension(path, vidExt) && !matchFileRegex(path, excludeVidRegex) { return f(path, info, err) } if !s.ExcludeImage { if (matchExtension(path, imgExt) || matchExtension(path, gExt)) && !matchFileRegex(path, excludeImgRegex) { return f(path, info, err) } } return nil }) }