lens @ 6e84441dab0a2b89869e33d7e89d14189d9b67c0

feat: Add thumbnailer
diff --git a/Makefile b/Makefile
index 397ebd3f43fff5b73d65830d2e445cfc8c60fb53..b18993b3a1d074320216b62d9f8344ba839d51fa 100644
--- a/Makefile
+++ b/Makefile
@@ -21,6 +21,7 @@ run: sass
 	$(GO_RUN) $(SERVER) \
 		--log-level error \
 		--aes-key=6368616e676520746869732070617373 \
+		--cache-path=${HOME}/.thumb \
 		--root=${HOME}
 
 sass:
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 702ca6e7756dc49c9e23d3a6483a0caada72042b..1bd445c277b1dafd0150aedc42880d55d3e008ca 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -33,6 +33,7 @@ 		dbType         = flag.String("db-type", "sqlite", "Database to be used. Choose either mysql, psql or sqlite")
 		dbCon          = flag.String("db-con", "main.db", "Database string connection for given database type. Ref: https://gorm.io/docs/connecting_to_the_database.html")
 		logLevel       = flag.String("log-level", "error", "Log level: Choose either trace, debug, info, warning, error, fatal or panic")
 		schedulerCount = flag.Uint("scheduler-count", 10, "How many workers are created to process media files")
+		cachePath      = flag.String("cache-path", "", "Folder to store thumbnail image")
 
 		// TODO: this will later be replaced by user specific root folder
 		root = flag.String("root", "", "root folder for the whole application. All the workers will use it as working directory")
@@ -112,8 +113,9 @@ 	}
 
 	// processors
 	var (
-		fileScanner = worker.NewFileScanner(*root, mediaRepository)
-		exifScanner = worker.NewEXIFScanner(mediaRepository)
+		fileScanner      = worker.NewFileScanner(*root, mediaRepository)
+		exifScanner      = worker.NewEXIFScanner(mediaRepository)
+		thumbnailScanner = worker.NewThumbnailScanner(*cachePath, mediaRepository)
 	)
 
 	// worker
@@ -129,12 +131,18 @@ 			exifScanner,
 			scheduler,
 			logrus.WithField("context", "exif scanner"),
 		)
+		thumbnailWorker = worker.NewWorkerFromBatchProcessor[*repository.Media](
+			thumbnailScanner,
+			scheduler,
+			logrus.WithField("context", "thumbnail scanner"),
+		)
 	)
 
 	pool := worker.NewWorkerPool()
 	pool.AddWorker("http server", serverWorker)
 	pool.AddWorker("exif scanner", exifWorker)
 	pool.AddWorker("file scanner", fileWorker)
+	pool.AddWorker("thumbnail scanner", thumbnailWorker)
 
 	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
 	defer stop()
diff --git a/go.mod b/go.mod
index 519070cc76069ed724e608d7b5492c233dcdc9d7..a473a48a10f27c01829261445a200578185a04dc 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.19
 
 require (
 	github.com/barasher/go-exiftool v1.10.0
+	github.com/disintegration/imaging v1.6.2
 	github.com/fasthttp/router v1.4.19
 	github.com/google/go-cmp v0.5.9
 	github.com/sirupsen/logrus v1.9.2
@@ -29,9 +30,10 @@ 	github.com/klauspost/compress v1.16.5 // indirect
 	github.com/mattn/go-sqlite3 v1.14.16 // indirect
 	github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
+	golang.org/x/image v0.8.0 // indirect
 	golang.org/x/mod v0.10.0 // indirect
 	golang.org/x/sys v0.8.0 // indirect
-	golang.org/x/text v0.9.0 // indirect
+	golang.org/x/text v0.10.0 // indirect
 	golang.org/x/tools v0.9.3 // indirect
 	gorm.io/datatypes v1.2.0 // indirect
 	gorm.io/hints v1.1.2 // indirect
diff --git a/go.sum b/go.sum
index b90e74709efdc06de5807a334e87b48862661cfc..8694f645250aa9eae9b6d9d74ca7452bc73887f2 100644
--- a/go.sum
+++ b/go.sum
@@ -5,6 +5,8 @@ github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 github.com/fasthttp/router v1.4.19 h1:RLE539IU/S4kfb4MP56zgP0TIBU9kEg0ID9GpWO0vqk=
 github.com/fasthttp/router v1.4.19/go.mod h1:+Fh3YOd8x1+he6ZS+d2iUDBH9MGGZ1xQFUor0DE9rKE=
 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
@@ -48,18 +50,51 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c=
 github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
 golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
+golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
 golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
+golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
 golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/pkg/database/repository/media.go b/pkg/database/repository/media.go
index 2e94ff3f45dd4fa6188e3ab389f68bed6e62b925..6ab4ee673adc2f12d4227113c9ef4822b5d34d0b 100644
--- a/pkg/database/repository/media.go
+++ b/pkg/database/repository/media.go
@@ -34,6 +34,10 @@ 		GPSLatitude     *float64
 		GPSLongitude    *float64
 	}
 
+	MediaThumbnail struct {
+		Path string
+	}
+
 	Pagination struct {
 		Page int
 		Size int
@@ -52,10 +56,15 @@ 		Exists(context.Context, string) (bool, error)
 		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 27f8cf0f642ad82c9b6bec7480d0d31ada616ab8..b8203f382fb83a92aa9d8447afda5918188166af 100644
--- a/pkg/database/sql/media.go
+++ b/pkg/database/sql/media.go
@@ -41,6 +41,13 @@ 		GPSLatitude     *float64
 		GPSLongitude    *float64
 	}
 
+	MediaThumbnail struct {
+		gorm.Model
+		Path    string
+		MediaID uint
+		Media   Media
+	}
+
 	MediaRepository struct {
 		db *gorm.DB
 	}
@@ -76,6 +83,12 @@ 		Orientation:     m.Orientation,
 		ExposureProgram: m.ExposureProgram,
 		GPSLatitude:     m.GPSLatitude,
 		GPSLongitude:    m.GPSLongitude,
+	}
+}
+
+func (m *MediaThumbnail) ToModel() *repository.MediaThumbnail {
+	return &repository.MediaThumbnail{
+		Path: m.Path,
 	}
 }
 
@@ -173,6 +186,24 @@
 	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 @@
 	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 @@ 	})
 
 	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 019eb91371ca24ec8f1b55fa1e6b80730c716ac0..076bf69da597bead7bd132bbad5473ad73c6be4e 100644
--- a/pkg/database/sql/migration.go
+++ b/pkg/database/sql/migration.go
@@ -8,6 +8,7 @@ 		&User{},
 		&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 0000000000000000000000000000000000000000..07c08e5d57eff695d8658ae7626adfb132e7f4d3
--- /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 0000000000000000000000000000000000000000..32f6064dc93654e14514875551c2c452fbe1d773
--- /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 ce9e27278588eb37a883deda70eaa4b6aa4edc9c..0b588f4f4c52b7aff77b48979490d43e703b95f8 100644
--- a/pkg/view/media.go
+++ b/pkg/view/media.go
@@ -93,9 +93,23 @@ 	fasthttp.ServeFileUncompressed(ctx, media.Path)
 	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 97790a083761672e3d03680d8e32be2eec902368..5ea18104d9be7dec5448e46321bc603d8d142e94 100644
--- a/pkg/worker/exif_scanner.go
+++ b/pkg/worker/exif_scanner.go
@@ -23,15 +23,10 @@ 	}
 }
 
 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 aa79035b77c5fb9724a149bbb0e29450583f0baa..b4f907ab682473e1082d0694b690c5a25a75c3a2 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 @@ 	return c, nil
 }
 
 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 @@ 		return nil
 	}
 
 	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 0000000000000000000000000000000000000000..cc201b830ff9d562380baa44cf80a2926cad9619
--- /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,
+	})
+}
diff --git a/templates/media.html b/templates/media.html
index 6302a573451a953b2a02275611a63377930d0db3..188d5b4c2be930218a1ad0f7b5527c53052fc3db 100644
--- a/templates/media.html
+++ b/templates/media.html
@@ -5,13 +5,13 @@ <div class="columns is-multiline">
 {{range .Data.Medias}}
 <div class="card">
     <div class="card-image">
-        {{ if .IsVideo }}
-        <video controls muted="true" preload="metadata">
-            <source src="/media/image?path_hash={{.PathHash}}" type="{{.MIMEType}}">
-        </video>
-        {{ else }}
+       {{ if .IsVideo }}
+       <video controls muted="true" poster="/media/thumbnail?path_hash={{.PathHash}}" preload="none">
+           <source src="/media/image?path_hash={{.PathHash}}" type="{{.MIMEType}}">
+       </video>
+       {{ else }}
         <figure class="image is-fit">
-            <img src="/media/image?path_hash={{.PathHash}}">
+            <img src="/media/thumbnail?path_hash={{.PathHash}}">
         </figure>
         {{ end }}
     </div>