From 69915575d5c3822aad029924989f182c75212cb8 Mon Sep 17 00:00:00 2001 From: Googol Lee Date: Fri, 18 Jul 2025 15:37:51 +0200 Subject: [PATCH] Add blurhash task, instead of a global blurhash generating. (#1250) --- api/scanner/scanner_album.go | 7 +- api/scanner/scanner_queue/queue.go | 12 --- api/scanner/scanner_tasks/blurhash_task.go | 87 ++++++++++++++++ .../processing_tasks/process_photo_task.go | 9 +- .../processing_tasks/process_video_task.go | 6 +- api/scanner/scanner_tasks/scanner_tasks.go | 4 + api/scanner/thumbnail_blurhash.go | 99 ------------------- api/test_utils/scanner/scanner_helpers.go | 9 -- 8 files changed, 102 insertions(+), 131 deletions(-) create mode 100644 api/scanner/scanner_tasks/blurhash_task.go delete mode 100644 api/scanner/thumbnail_blurhash.go diff --git a/api/scanner/scanner_album.go b/api/scanner/scanner_album.go index efa293e5..e5af6dab 100644 --- a/api/scanner/scanner_album.go +++ b/api/scanner/scanner_album.go @@ -2,11 +2,11 @@ package scanner import ( "fmt" - "log" "os" "path" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/log" "github.com/photoview/photoview/api/scanner/media_encoding" "github.com/photoview/photoview/api/scanner/scanner_task" "github.com/photoview/photoview/api/scanner/scanner_tasks" @@ -77,7 +77,7 @@ var ErrorInvalidRootPath = errors.New("invalid root path") func ValidRootPath(rootPath string) bool { _, err := os.Stat(rootPath) if err != nil { - log.Printf("Warn: invalid root path: '%s'\n%s\n", rootPath, err) + log.Warn(nil, "invalid root path", "root_path", rootPath, "error", err) return false } @@ -124,10 +124,11 @@ func findMediaForAlbum(ctx scanner_task.TaskContext) ([]*models.Media, error) { for _, item := range dirContent { mediaPath := path.Join(ctx.GetAlbum().Path, item.Name()) + log.Info(ctx, "Check the media", "media_path", mediaPath) isDirSymlink, err := utils.IsDirSymlink(mediaPath) if err != nil { - log.Printf("Cannot detect whether %s is symlink to a directory. Pretending it is not", mediaPath) + log.Warn(ctx, "Cannot detect whether the path is symlink to a directory. Pretending it is not", "media_path", mediaPath) isDirSymlink = false } diff --git a/api/scanner/scanner_queue/queue.go b/api/scanner/scanner_queue/queue.go index a24e8fdd..f7bd9db3 100644 --- a/api/scanner/scanner_queue/queue.go +++ b/api/scanner/scanner_queue/queue.go @@ -172,18 +172,6 @@ func (queue *ScannerQueue) processQueue(notifyThrottle *utils.Throttle) { queue.mutex.Unlock() if inProgressLength+upNextLength == 0 { - notification.BroadcastNotification(&models.Notification{ - Key: globalScannerProgress, - Type: models.NotificationTypeMessage, - Header: "Generating blurhashes", - Content: "Generating blurhashes for newly scanned media", - Positive: true, - }) - - if err := scanner.GenerateBlurhashes(queue.db); err != nil { - scanner_utils.ScannerError(nil, "Failed to generate blurhashes: %v", err) - } - notification.BroadcastNotification(&models.Notification{ Key: globalScannerProgress, Type: models.NotificationTypeMessage, diff --git a/api/scanner/scanner_tasks/blurhash_task.go b/api/scanner/scanner_tasks/blurhash_task.go new file mode 100644 index 00000000..6cbee79e --- /dev/null +++ b/api/scanner/scanner_tasks/blurhash_task.go @@ -0,0 +1,87 @@ +package scanner_tasks + +import ( + "fmt" + "image" + _ "image/jpeg" + "os" + + "github.com/buckket/go-blurhash" + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/log" + "github.com/photoview/photoview/api/scanner/media_encoding" + "github.com/photoview/photoview/api/scanner/scanner_task" +) + +type BlurhashTask struct { + scanner_task.ScannerTaskBase +} + +func (t BlurhashTask) AfterProcessMedia(ctx scanner_task.TaskContext, mediaData *media_encoding.EncodeMediaData, updatedURLs []*models.MediaURL, mediaIndex int, mediaTotal int) error { + hasThumbnailUpdated := false + for _, url := range updatedURLs { + if url.Purpose == models.PhotoThumbnail || url.Purpose == models.VideoThumbnail { + hasThumbnailUpdated = true + break + } + } + + var media *models.Media + if err := ctx.GetDB().Preload("MediaURL").Where("id = ?", mediaData.Media.ID).First(&media).Error; err != nil { + return fmt.Errorf("failed to get media(id:%d): %w", mediaData.Media.ID, err) + } + + if media.Blurhash != nil && !hasThumbnailUpdated { + log.Info(ctx, "No thumbnail updated, ignore generating blurhash", "media", media.Path) + return nil + } + + thumbnail, err := media.GetThumbnail() + if err != nil { + return fmt.Errorf("failed to get thumbnail of image %q: %w", mediaData.Media.Path, err) + } + + hashStr, err := generateBlurhashFromThumbnail(thumbnail) + if err != nil { + return fmt.Errorf("failed to generate blurhash of image %q: %w", mediaData.Media.Path, err) + } + + media.Blurhash = &hashStr + if err := ctx.GetDB().Select("blurhash").Save(media).Error; err != nil { + return fmt.Errorf("failed to store blurhash of image %q: %w", mediaData.Media.Path, err) + } + + log.Info(ctx, "Generated blurhash of image", "media", mediaData.Media.Path) + + return nil +} + +// generateBlurhashFromThumbnail generates a blurhash for a single media and stores it in the database +func generateBlurhashFromThumbnail(thumbnail *models.MediaURL) (string, error) { + path, err := thumbnail.CachedPath() + if err != nil { + return "", fmt.Errorf("get path of media(id:%d) error: %w", thumbnail.MediaID, err) + } + + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open %q error: %w", path, err) + } + defer f.Close() + + imageData, _, err := image.Decode(f) + if err != nil { + return "", fmt.Errorf("decode %q error: %w", path, err) + } + + const ( + componentX = 4 + componentY = 3 + ) + hashStr, err := blurhash.Encode(componentX, componentY, imageData) + if err != nil { + return "", fmt.Errorf("encode blurhash of %q error: %w", path, err) + } + + return hashStr, nil +} diff --git a/api/scanner/scanner_tasks/processing_tasks/process_photo_task.go b/api/scanner/scanner_tasks/processing_tasks/process_photo_task.go index 1c28e3d1..b5088d5a 100644 --- a/api/scanner/scanner_tasks/processing_tasks/process_photo_task.go +++ b/api/scanner/scanner_tasks/processing_tasks/process_photo_task.go @@ -1,12 +1,11 @@ package processing_tasks import ( - "fmt" - "log" "os" "path" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/log" "github.com/photoview/photoview/api/scanner/media_encoding" "github.com/photoview/photoview/api/scanner/scanner_task" "github.com/pkg/errors" @@ -24,7 +23,7 @@ func (t ProcessPhotoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData * updatedURLs := make([]*models.MediaURL, 0) photo := mediaData.Media - log.Printf("Processing photo: %s\n", photo.Path) + log.Info(ctx, "Processing photo", "photo", photo.Path) photoURLFromDB := makePhotoURLChecker(ctx.GetDB(), photo.ID) @@ -72,7 +71,7 @@ func (t ProcessPhotoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData * baseImagePath = path.Join(mediaCachePath, highResURL.MediaName) if _, err := os.Stat(baseImagePath); os.IsNotExist(err) { - fmt.Printf("High-res photo found in database but not in cache, re-encoding photo to cache: %s\n", highResURL.MediaName) + log.Info(ctx, "High-res photo found in database but not in cache, re-encoding photo to cache", "media_name", highResURL.MediaName) updatedURLs = append(updatedURLs, highResURL) err = mediaData.EncodeHighRes(baseImagePath) @@ -114,7 +113,7 @@ func (t ProcessPhotoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData * if _, err := os.Stat(thumbPath); os.IsNotExist(err) { updatedURLs = append(updatedURLs, thumbURL) - fmt.Printf("Thumbnail photo found in database but not in cache, re-encoding photo to cache: %s\n", thumbURL.MediaName) + log.Info(ctx, "Thumbnail photo found in database but not in cache, re-encoding photo to cache", "media_name", thumbURL.MediaName) _, err := media_encoding.EncodeThumbnail(ctx.GetDB(), baseImagePath, thumbPath) if err != nil { diff --git a/api/scanner/scanner_tasks/processing_tasks/process_video_task.go b/api/scanner/scanner_tasks/processing_tasks/process_video_task.go index b4a9f05f..76605daf 100644 --- a/api/scanner/scanner_tasks/processing_tasks/process_video_task.go +++ b/api/scanner/scanner_tasks/processing_tasks/process_video_task.go @@ -3,13 +3,13 @@ package processing_tasks import ( "context" "fmt" - "log" "os" "path" "strings" "time" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/log" "github.com/photoview/photoview/api/scanner/media_encoding" "github.com/photoview/photoview/api/scanner/media_encoding/executable_worker" "github.com/photoview/photoview/api/scanner/scanner_task" @@ -30,7 +30,7 @@ func (t ProcessVideoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData * updatedURLs := make([]*models.MediaURL, 0) video := mediaData.Media - log.Printf("Processing video: %s", video.Path) + log.Info(ctx, "Processing video", "video", video.Path) mediaURLFromDB := makePhotoURLChecker(ctx.GetDB(), video.ID) @@ -173,7 +173,7 @@ func (t ProcessVideoTask) ProcessMedia(ctx scanner_task.TaskContext, mediaData * thumbImagePath := path.Join(mediaCachePath, videoThumbnailURL.MediaName) if _, err := os.Stat(thumbImagePath); os.IsNotExist(err) { - fmt.Printf("Video thumbnail found in database but not in cache, re-encoding photo to cache: %s\n", videoThumbnailURL.MediaName) + log.Info(ctx, "Video thumbnail found in database but not in cache, re-encoding video thumbnail to cache", "video", videoThumbnailURL.MediaName) updatedURLs = append(updatedURLs, videoThumbnailURL) err = executable_worker.Ffmpeg.EncodeVideoThumbnail(video.Path, thumbImagePath, probeData) diff --git a/api/scanner/scanner_tasks/scanner_tasks.go b/api/scanner/scanner_tasks/scanner_tasks.go index a90c93fa..509aa4f2 100644 --- a/api/scanner/scanner_tasks/scanner_tasks.go +++ b/api/scanner/scanner_tasks/scanner_tasks.go @@ -1,9 +1,11 @@ package scanner_tasks import ( + "fmt" "io/fs" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/log" "github.com/photoview/photoview/api/scanner/media_encoding" "github.com/photoview/photoview/api/scanner/scanner_task" "github.com/photoview/photoview/api/scanner/scanner_tasks/cleanup_tasks" @@ -18,6 +20,7 @@ var allTasks []scanner_task.ScannerTask = []scanner_task.ScannerTask{ processing_tasks.ProcessPhotoTask{}, processing_tasks.ProcessVideoTask{}, FaceDetectionTask{}, + BlurhashTask{}, ExifTask{}, VideoMetadataTask{}, cleanup_tasks.MediaCleanupTask{}, @@ -80,6 +83,7 @@ func (t scannerTasks) MediaFound(ctx scanner_task.TaskContext, fileInfo fs.FileI } if skip { + log.Info(ctx, "skip the media", "media_path", mediaPath, "by_task", fmt.Sprintf("%T", task)) return true, nil } } diff --git a/api/scanner/thumbnail_blurhash.go b/api/scanner/thumbnail_blurhash.go deleted file mode 100644 index e6f20064..00000000 --- a/api/scanner/thumbnail_blurhash.go +++ /dev/null @@ -1,99 +0,0 @@ -package scanner - -import ( - "fmt" - "image" - _ "image/jpeg" - "log" - "os" - - "github.com/buckket/go-blurhash" - "github.com/photoview/photoview/api/graphql/models" - "gorm.io/gorm" -) - -// GenerateBlurhashes queries the database for media that are missing a blurhash and computes one for them. -// This function blocks until all hashes have been computed -func GenerateBlurhashes(db *gorm.DB) error { - var results []*models.Media - - processErrors := make([]error, 0) - - query := db.Model(&models.Media{}). - Preload("MediaURL"). - Joins("INNER JOIN media_urls ON media.id = media_urls.media_id"). - Where("blurhash IS NULL"). - Where("media_urls.purpose = 'thumbnail' OR media_urls.purpose = 'video-thumbnail'") - - err := query.FindInBatches(&results, 50, func(tx *gorm.DB, batch int) error { - log.Printf("generating %d blurhashes", len(results)) - - hashes := make([]*string, len(results)) - - for i, row := range results { - - thumbnail, err := row.GetThumbnail() - if err != nil { - log.Printf("failed to get thumbnail for media to generate blurhash (%d): %v", row.ID, err) - processErrors = append(processErrors, err) - continue - } - - hashStr, err := GenerateBlurhashFromThumbnail(thumbnail) - if err != nil { - log.Printf("failed to generate blurhash: %v", err) - processErrors = append(processErrors, err) - continue - } - - hashes[i] = &hashStr - results[i].Blurhash = &hashStr - } - - tx.Save(results) - // if err := db.Update("blurhash", hashes).Error; err != nil { - // return err - // } - - return nil - }).Error - - if err != nil { - return err - } - - if len(processErrors) == 0 { - return nil - } else { - return fmt.Errorf("failed to generate %d blurhashes", len(processErrors)) - } -} - -// GenerateBlurhashFromThumbnail generates a blurhash for a single media and stores it in the database -func GenerateBlurhashFromThumbnail(thumbnail *models.MediaURL) (string, error) { - thumbnail_path, err := thumbnail.CachedPath() - if err != nil { - return "", fmt.Errorf("get path of media id=%d error: %w", thumbnail.MediaID, err) - } - - imageFile, err := os.Open(thumbnail_path) - if err != nil { - return "", fmt.Errorf("open %s error: %w", thumbnail_path, err) - } - - imageData, _, err := image.Decode(imageFile) - if err != nil { - return "", fmt.Errorf("decode %q error: %w", thumbnail_path, err) - } - - hashStr, err := blurhash.Encode(4, 3, imageData) - if err != nil { - return "", fmt.Errorf("encode blurhash of %q error: %w", thumbnail_path, err) - } - - // if err := db.Model(&models.Media{}).Where("id = ?", thumbnail.MediaID).Update("blurhash", hashStr).Error; err != nil { - // return "", fmt.Errorf("update blurhash of media id=%d error: %w", thumbnail.MediaID, err) - // } - - return hashStr, nil -} diff --git a/api/test_utils/scanner/scanner_helpers.go b/api/test_utils/scanner/scanner_helpers.go index 70302c95..ae9af415 100644 --- a/api/test_utils/scanner/scanner_helpers.go +++ b/api/test_utils/scanner/scanner_helpers.go @@ -5,7 +5,6 @@ import ( "time" "github.com/photoview/photoview/api/graphql/models" - "github.com/photoview/photoview/api/scanner" "github.com/photoview/photoview/api/scanner/scanner_queue" "github.com/stretchr/testify/assert" "gorm.io/gorm" @@ -28,10 +27,6 @@ func RunScannerOnUser(t *testing.T, db *gorm.DB, user *models.User) { // wait for all jobs to finish scanner_queue.CloseScannerQueue() - - if err := scanner.GenerateBlurhashes(db); err != nil { - t.Fatalf("generate blurhashes error: %v", err) - } } func RunScannerAll(t *testing.T, db *gorm.DB) { @@ -51,8 +46,4 @@ func RunScannerAll(t *testing.T, db *gorm.DB) { // wait for all jobs to finish scanner_queue.CloseScannerQueue() - - if err := scanner.GenerateBlurhashes(db); err != nil { - t.Fatalf("generate blurhashes error: %v", err) - } }