Add blurhash task, instead of a global blurhash generating. (#1250)

This commit is contained in:
Googol Lee
2025-07-18 15:37:51 +02:00
committed by GitHub
parent 50209bc55c
commit 69915575d5
8 changed files with 102 additions and 131 deletions

View File

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

View File

@@ -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,

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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