diff options
| author | Gabriel Arakaki Giovanini <mail@gabrielgio.me> | 2023-07-01 17:55:50 +0200 | 
|---|---|---|
| committer | Gabriel Arakaki Giovanini <mail@gabrielgio.me> | 2023-07-01 17:55:50 +0200 | 
| commit | 6e84441dab0a2b89869e33d7e89d14189d9b67c0 (patch) | |
| tree | e015839d495bcfc7619f4efd08f97a1ba603fd82 /pkg | |
| parent | 3f0dc691e2248cc21edd2e74a62b8f28ce95559e (diff) | |
| download | lens-6e84441dab0a2b89869e33d7e89d14189d9b67c0.tar.gz lens-6e84441dab0a2b89869e33d7e89d14189d9b67c0.tar.bz2 lens-6e84441dab0a2b89869e33d7e89d14189d9b67c0.zip | |
feat: Add thumbnailer
Diffstat (limited to 'pkg')
| -rw-r--r-- | pkg/database/repository/media.go | 11 | ||||
| -rw-r--r-- | pkg/database/sql/media.go | 88 | ||||
| -rw-r--r-- | pkg/database/sql/migration.go | 1 | ||||
| -rw-r--r-- | pkg/fileop/file.go | 17 | ||||
| -rw-r--r-- | pkg/fileop/thumbnail.go | 60 | ||||
| -rw-r--r-- | pkg/view/media.go | 14 | ||||
| -rw-r--r-- | pkg/worker/exif_scanner.go | 7 | ||||
| -rw-r--r-- | pkg/worker/file_scanner.go | 25 | ||||
| -rw-r--r-- | pkg/worker/thumbnail_scanner.go | 62 | 
9 files changed, 263 insertions, 22 deletions
| diff --git a/pkg/database/repository/media.go b/pkg/database/repository/media.go index 2e94ff3..6ab4ee6 100644 --- a/pkg/database/repository/media.go +++ b/pkg/database/repository/media.go @@ -34,6 +34,10 @@ type (  		GPSLongitude    *float64  	} +	MediaThumbnail struct { +		Path string +	} +  	Pagination struct {  		Page int  		Size int @@ -52,10 +56,15 @@ type (  		List(context.Context, *Pagination) ([]*Media, error)  		Get(context.Context, string) (*Media, error)  		GetPath(context.Context, string) (string, error) +		GetThumbnailPath(context.Context, string) (string, error) -		GetEmptyEXIF(context.Context, *Pagination) ([]*Media, error) +		ListEmptyEXIF(context.Context, *Pagination) ([]*Media, error)  		GetEXIF(context.Context, uint) (*MediaEXIF, error)  		CreateEXIF(context.Context, uint, *MediaEXIF) error + +		ListEmptyThumbnail(context.Context, *Pagination) ([]*Media, error) +		GetThumbnail(context.Context, uint) (*MediaThumbnail, error) +		CreateThumbnail(context.Context, uint, *MediaThumbnail) error  	}  ) diff --git a/pkg/database/sql/media.go b/pkg/database/sql/media.go index 27f8cf0..b8203f3 100644 --- a/pkg/database/sql/media.go +++ b/pkg/database/sql/media.go @@ -41,6 +41,13 @@ type (  		GPSLongitude    *float64  	} +	MediaThumbnail struct { +		gorm.Model +		Path    string +		MediaID uint +		Media   Media +	} +  	MediaRepository struct {  		db *gorm.DB  	} @@ -79,6 +86,12 @@ func (m *MediaEXIF) ToModel() *repository.MediaEXIF {  	}  } +func (m *MediaThumbnail) ToModel() *repository.MediaThumbnail { +	return &repository.MediaThumbnail{ +		Path: m.Path, +	} +} +  func NewMediaRepository(db *gorm.DB) *MediaRepository {  	return &MediaRepository{  		db: db, @@ -173,6 +186,24 @@ func (self *MediaRepository) GetPath(ctx context.Context, pathHash string) (stri  	return path, nil  } +func (self *MediaRepository) GetThumbnailPath(ctx context.Context, pathHash string) (string, error) { +	var path string +	result := self.db. +		WithContext(ctx). +		Model(&Media{}). +		Select("media_thumbnails.path"). +		Joins("left join media_thumbnails on media.id = media_thumbnails.media_id"). +		Where("media.path_hash = ?", pathHash). +		Limit(1). +		Find(&path) + +	if result.Error != nil { +		return "", result.Error +	} + +	return path, nil +} +  func (m *MediaRepository) GetEXIF(ctx context.Context, mediaID uint) (*repository.MediaEXIF, error) {  	exif := &MediaEXIF{}  	result := m.db. @@ -220,7 +251,7 @@ func (s *MediaRepository) CreateEXIF(ctx context.Context, id uint, info *reposit  	return nil  } -func (r *MediaRepository) GetEmptyEXIF(ctx context.Context, pagination *repository.Pagination) ([]*repository.Media, error) { +func (r *MediaRepository) ListEmptyEXIF(ctx context.Context, pagination *repository.Pagination) ([]*repository.Media, error) {  	medias := make([]*Media, 0)  	result := r.db.  		WithContext(ctx). @@ -242,3 +273,58 @@ func (r *MediaRepository) GetEmptyEXIF(ctx context.Context, pagination *reposito  	return m, nil  } + +func (r *MediaRepository) ListEmptyThumbnail(ctx context.Context, pagination *repository.Pagination) ([]*repository.Media, error) { +	medias := make([]*Media, 0) +	result := r.db. +		WithContext(ctx). +		Model(&Media{}). +		Joins("left join media_thumbnails on media.id = media_thumbnails.media_id"). +		Where("media_thumbnails.media_id IS NULL"). +		Offset(pagination.Page * pagination.Size). +		Limit(pagination.Size). +		Order("media.created_at DESC"). +		Find(&medias) + +	if result.Error != nil { +		return nil, result.Error +	} + +	m := list.Map(medias, func(s *Media) *repository.Media { +		return s.ToModel() +	}) + +	return m, nil +} + +func (m *MediaRepository) GetThumbnail(ctx context.Context, mediaID uint) (*repository.MediaThumbnail, error) { +	thumbnail := &MediaThumbnail{} +	result := m.db. +		WithContext(ctx). +		Model(&Media{}). +		Where("media_id = ?", mediaID). +		Limit(1). +		Take(m) + +	if result.Error != nil { +		return nil, result.Error +	} + +	return thumbnail.ToModel(), nil +} + +func (m *MediaRepository) CreateThumbnail(ctx context.Context, mediaID uint, thumbnail *repository.MediaThumbnail) error { +	media := &MediaThumbnail{ +		MediaID: mediaID, +		Path:    thumbnail.Path, +	} + +	result := m.db. +		WithContext(ctx). +		Create(media) +	if result.Error != nil { +		return result.Error +	} + +	return nil +} diff --git a/pkg/database/sql/migration.go b/pkg/database/sql/migration.go index 019eb91..076bf69 100644 --- a/pkg/database/sql/migration.go +++ b/pkg/database/sql/migration.go @@ -8,6 +8,7 @@ func Migrate(db *gorm.DB) error {  		&Settings{},  		&Media{},  		&MediaEXIF{}, +		&MediaThumbnail{},  	} {  		if err := db.AutoMigrate(m); err != nil {  			return err diff --git a/pkg/fileop/file.go b/pkg/fileop/file.go new file mode 100644 index 0000000..07c08e5 --- /dev/null +++ b/pkg/fileop/file.go @@ -0,0 +1,17 @@ +package fileop + +import ( +	"crypto/md5" +	"encoding/hex" +	"strings" +) + +func GetHashFromPath(path string) string { +	hash := md5.Sum([]byte(path)) +	return hex.EncodeToString(hash[:]) +} + +func IsMimeTypeSupported(mimetype string) bool { +	return strings.HasPrefix(mimetype, "video") && +		strings.HasPrefix(mimetype, "image") +} diff --git a/pkg/fileop/thumbnail.go b/pkg/fileop/thumbnail.go new file mode 100644 index 0000000..32f6064 --- /dev/null +++ b/pkg/fileop/thumbnail.go @@ -0,0 +1,60 @@ +package fileop + +import ( +	"image" +	"image/jpeg" +	"os" +	"os/exec" + +	"github.com/disintegration/imaging" +) + +func EncodeImageThumbnail(inputPath string, outputPath string, width, height int) error { +	inputImage, err := imaging.Open(inputPath, imaging.AutoOrientation(true)) +	if err != nil { +		return err +	} + +	thumbImage := imaging.Fit(inputImage, width, height, imaging.Lanczos) +	if err = encodeImageJPEG(thumbImage, outputPath, 60); err != nil { +		return err +	} + +	return nil +} + +func encodeImageJPEG(image image.Image, outputPath string, jpegQuality int) error { +	photo_file, err := os.Create(outputPath) +	if err != nil { +		return err +	} +	defer photo_file.Close() + +	err = jpeg.Encode(photo_file, image, &jpeg.Options{Quality: jpegQuality}) +	if err != nil { +		return err +	} + +	return nil +} + +func EncodeVideoThumbnail(inputPath string, outputPath string, width, height int) error { +	args := []string{ +		"-i", +		inputPath, +		"-vframes", "1", // output one frame +		"-an", // disable audio +		"-vf", "scale='min(1024,iw)':'min(1024,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2", +		"-vf", "select=gte(n\\,100)", +		outputPath, +	} + +	cmd := exec.Command("ffmpeg", args...) + +	if err := cmd.Run(); err != nil { +		return err +	} + +	return nil + +} diff --git a/pkg/view/media.go b/pkg/view/media.go index ce9e272..0b588f4 100644 --- a/pkg/view/media.go +++ b/pkg/view/media.go @@ -93,9 +93,23 @@ func (self *MediaView) GetImage(ctx *fasthttp.RequestCtx) error {  	return nil  } +func (self *MediaView) GetThumbnail(ctx *fasthttp.RequestCtx) error { +	pathHash := string(ctx.FormValue("path_hash")) + +	path, err := self.mediaRepository.GetThumbnailPath(ctx, pathHash) +	if err != nil { +		return self.GetImage(ctx) +	} + +	ctx.Request.Header.SetContentType("image/jpeg") +	fasthttp.ServeFileUncompressed(ctx, path) +	return nil +} +  func (self *MediaView) SetMyselfIn(r *ext.Router) {  	r.GET("/media", self.Index)  	r.POST("/media", self.Index)  	r.GET("/media/image", self.GetImage) +	r.GET("/media/thumbnail", self.GetThumbnail)  } diff --git a/pkg/worker/exif_scanner.go b/pkg/worker/exif_scanner.go index 97790a0..5ea1810 100644 --- a/pkg/worker/exif_scanner.go +++ b/pkg/worker/exif_scanner.go @@ -23,15 +23,10 @@ func NewEXIFScanner(repository repository.MediaRepository) *EXIFScanner {  }  func (e *EXIFScanner) Query(ctx context.Context) ([]*repository.Media, error) { -	medias, err := e.repository.GetEmptyEXIF(ctx, &repository.Pagination{ +	return e.repository.ListEmptyEXIF(ctx, &repository.Pagination{  		Page: 0,  		Size: 100,  	}) -	if err != nil { -		return nil, err -	} - -	return medias, nil  }  func (e *EXIFScanner) Process(ctx context.Context, m *repository.Media) error { diff --git a/pkg/worker/file_scanner.go b/pkg/worker/file_scanner.go index aa79035..b4f907a 100644 --- a/pkg/worker/file_scanner.go +++ b/pkg/worker/file_scanner.go @@ -2,14 +2,12 @@ package worker  import (  	"context" -	"crypto/md5" -	"encoding/hex"  	"io/fs"  	"mime"  	"path/filepath" -	"strings"  	"git.sr.ht/~gabrielgio/img/pkg/database/repository" +	"git.sr.ht/~gabrielgio/img/pkg/fileop"  )  type ( @@ -59,18 +57,17 @@ func (f *FileScanner) Query(ctx context.Context) (<-chan string, error) {  }  func (f *FileScanner) Process(ctx context.Context, path string) error { -	m := mime.TypeByExtension(filepath.Ext(path)) -	if !strings.HasPrefix(m, "video") && !strings.HasPrefix(m, "image") { +	mimetype := mime.TypeByExtension(filepath.Ext(path)) +	supported := fileop.IsMimeTypeSupported(mimetype) +	if !supported {  		return nil  	} -	hash := md5.Sum([]byte(path)) -	str := hex.EncodeToString(hash[:]) -	name := filepath.Base(path) +	hash := fileop.GetHashFromPath(path) -	exists, errResp := f.repository.Exists(ctx, str) -	if errResp != nil { -		return errResp +	exists, err := f.repository.Exists(ctx, hash) +	if err != nil { +		return err  	}  	if exists { @@ -78,9 +75,9 @@ func (f *FileScanner) Process(ctx context.Context, path string) error {  	}  	return f.repository.Create(ctx, &repository.CreateMedia{ -		Name:     name, +		Name:     filepath.Base(path),  		Path:     path, -		PathHash: str, -		MIMEType: m, +		PathHash: hash, +		MIMEType: mimetype,  	})  } diff --git a/pkg/worker/thumbnail_scanner.go b/pkg/worker/thumbnail_scanner.go new file mode 100644 index 0000000..cc201b8 --- /dev/null +++ b/pkg/worker/thumbnail_scanner.go @@ -0,0 +1,62 @@ +package worker + +import ( +	"context" +	"math" +	"os" +	"path" + +	"git.sr.ht/~gabrielgio/img/pkg/database/repository" +	"git.sr.ht/~gabrielgio/img/pkg/fileop" +) + +type ( +	ThumbnailScanner struct { +		repository repository.MediaRepository +		cachePath  string +	} +) + +var _ BatchProcessor[*repository.Media] = &EXIFScanner{} + +func NewThumbnailScanner(cachePath string, repository repository.MediaRepository) *ThumbnailScanner { +	return &ThumbnailScanner{ +		repository: repository, +		cachePath:  cachePath, +	} +} + +func (t *ThumbnailScanner) Query(ctx context.Context) ([]*repository.Media, error) { +	return t.repository.ListEmptyThumbnail(ctx, &repository.Pagination{ +		Page: 0, +		Size: 100, +	}) +} + +func (t *ThumbnailScanner) Process(ctx context.Context, media *repository.Media) error { +	split := media.PathHash[:2] +	filename := media.PathHash[2:] +	folder := path.Join(t.cachePath, split) +	output := path.Join(folder, filename+".jpeg") + +	err := os.MkdirAll(folder, os.ModePerm) +	if err != nil { +		return err +	} + +	if media.IsVideo() { +		err := fileop.EncodeVideoThumbnail(media.Path, output, 1080, 1080) +		if err != nil { +			return err +		} +	} else { +		err := fileop.EncodeImageThumbnail(media.Path, output, 1080, math.MaxInt) +		if err != nil { +			return err +		} +	} + +	return t.repository.CreateThumbnail(ctx, media.ID, &repository.MediaThumbnail{ +		Path: output, +	}) +} | 
