aboutsummaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
Diffstat (limited to 'pkg')
-rw-r--r--pkg/database/repository/media.go11
-rw-r--r--pkg/database/sql/media.go88
-rw-r--r--pkg/database/sql/migration.go1
-rw-r--r--pkg/fileop/file.go17
-rw-r--r--pkg/fileop/thumbnail.go60
-rw-r--r--pkg/view/media.go14
-rw-r--r--pkg/worker/exif_scanner.go7
-rw-r--r--pkg/worker/file_scanner.go25
-rw-r--r--pkg/worker/thumbnail_scanner.go62
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,
+ })
+}