lens @ 6e84441dab0a2b89869e33d7e89d14189d9b67c0

  1diff --git a/Makefile b/Makefile
  2index 397ebd3f43fff5b73d65830d2e445cfc8c60fb53..b18993b3a1d074320216b62d9f8344ba839d51fa 100644
  3--- a/Makefile
  4+++ b/Makefile
  5@@ -21,6 +21,7 @@ run: sass
  6 	$(GO_RUN) $(SERVER) \
  7 		--log-level error \
  8 		--aes-key=6368616e676520746869732070617373 \
  9+		--cache-path=${HOME}/.thumb \
 10 		--root=${HOME}
 11 
 12 sass:
 13diff --git a/cmd/server/main.go b/cmd/server/main.go
 14index 702ca6e7756dc49c9e23d3a6483a0caada72042b..1bd445c277b1dafd0150aedc42880d55d3e008ca 100644
 15--- a/cmd/server/main.go
 16+++ b/cmd/server/main.go
 17@@ -33,6 +33,7 @@ 		dbType         = flag.String("db-type", "sqlite", "Database to be used. Choose either mysql, psql or sqlite")
 18 		dbCon          = flag.String("db-con", "main.db", "Database string connection for given database type. Ref: https://gorm.io/docs/connecting_to_the_database.html")
 19 		logLevel       = flag.String("log-level", "error", "Log level: Choose either trace, debug, info, warning, error, fatal or panic")
 20 		schedulerCount = flag.Uint("scheduler-count", 10, "How many workers are created to process media files")
 21+		cachePath      = flag.String("cache-path", "", "Folder to store thumbnail image")
 22 
 23 		// TODO: this will later be replaced by user specific root folder
 24 		root = flag.String("root", "", "root folder for the whole application. All the workers will use it as working directory")
 25@@ -112,8 +113,9 @@ 	}
 26 
 27 	// processors
 28 	var (
 29-		fileScanner = worker.NewFileScanner(*root, mediaRepository)
 30-		exifScanner = worker.NewEXIFScanner(mediaRepository)
 31+		fileScanner      = worker.NewFileScanner(*root, mediaRepository)
 32+		exifScanner      = worker.NewEXIFScanner(mediaRepository)
 33+		thumbnailScanner = worker.NewThumbnailScanner(*cachePath, mediaRepository)
 34 	)
 35 
 36 	// worker
 37@@ -129,12 +131,18 @@ 			exifScanner,
 38 			scheduler,
 39 			logrus.WithField("context", "exif scanner"),
 40 		)
 41+		thumbnailWorker = worker.NewWorkerFromBatchProcessor[*repository.Media](
 42+			thumbnailScanner,
 43+			scheduler,
 44+			logrus.WithField("context", "thumbnail scanner"),
 45+		)
 46 	)
 47 
 48 	pool := worker.NewWorkerPool()
 49 	pool.AddWorker("http server", serverWorker)
 50 	pool.AddWorker("exif scanner", exifWorker)
 51 	pool.AddWorker("file scanner", fileWorker)
 52+	pool.AddWorker("thumbnail scanner", thumbnailWorker)
 53 
 54 	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
 55 	defer stop()
 56diff --git a/go.mod b/go.mod
 57index 519070cc76069ed724e608d7b5492c233dcdc9d7..a473a48a10f27c01829261445a200578185a04dc 100644
 58--- a/go.mod
 59+++ b/go.mod
 60@@ -4,6 +4,7 @@ go 1.19
 61 
 62 require (
 63 	github.com/barasher/go-exiftool v1.10.0
 64+	github.com/disintegration/imaging v1.6.2
 65 	github.com/fasthttp/router v1.4.19
 66 	github.com/google/go-cmp v0.5.9
 67 	github.com/sirupsen/logrus v1.9.2
 68@@ -29,9 +30,10 @@ 	github.com/klauspost/compress v1.16.5 // indirect
 69 	github.com/mattn/go-sqlite3 v1.14.16 // indirect
 70 	github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
 71 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 72+	golang.org/x/image v0.8.0 // indirect
 73 	golang.org/x/mod v0.10.0 // indirect
 74 	golang.org/x/sys v0.8.0 // indirect
 75-	golang.org/x/text v0.9.0 // indirect
 76+	golang.org/x/text v0.10.0 // indirect
 77 	golang.org/x/tools v0.9.3 // indirect
 78 	gorm.io/datatypes v1.2.0 // indirect
 79 	gorm.io/hints v1.1.2 // indirect
 80diff --git a/go.sum b/go.sum
 81index b90e74709efdc06de5807a334e87b48862661cfc..8694f645250aa9eae9b6d9d74ca7452bc73887f2 100644
 82--- a/go.sum
 83+++ b/go.sum
 84@@ -5,6 +5,8 @@ github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
 85 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 86 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 87 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 88+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
 89+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 90 github.com/fasthttp/router v1.4.19 h1:RLE539IU/S4kfb4MP56zgP0TIBU9kEg0ID9GpWO0vqk=
 91 github.com/fasthttp/router v1.4.19/go.mod h1:+Fh3YOd8x1+he6ZS+d2iUDBH9MGGZ1xQFUor0DE9rKE=
 92 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 93@@ -48,18 +50,51 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 94 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 95 github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c=
 96 github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
 97+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 98+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 99+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
100 golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
101 golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
102+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
103+golang.org/x/image v0.8.0 h1:agUcRXV/+w6L9ryntYYsF2x9fQTMd4T8fiiYXAVW6Jg=
104+golang.org/x/image v0.8.0/go.mod h1:PwLxp3opCYg4WR2WO9P0L6ESnsD6bLTWcw8zanLMVFM=
105+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
106+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
107 golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
108 golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
109+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
110+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
111+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
112+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
113+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
114+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
115+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
116 golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
117+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
118+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
119+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
120+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
121 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
122+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
123+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
124 golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
125 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
126-golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
127-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
128+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
129+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
130+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
131+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
132+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
133+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
134+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
135+golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
136+golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
137+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
138+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
139+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
140+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
141 golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM=
142 golang.org/x/tools v0.9.3/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
143+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
144 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
145 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
146 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
147diff --git a/pkg/database/repository/media.go b/pkg/database/repository/media.go
148index 2e94ff3f45dd4fa6188e3ab389f68bed6e62b925..6ab4ee673adc2f12d4227113c9ef4822b5d34d0b 100644
149--- a/pkg/database/repository/media.go
150+++ b/pkg/database/repository/media.go
151@@ -34,6 +34,10 @@ 		GPSLatitude     *float64
152 		GPSLongitude    *float64
153 	}
154 
155+	MediaThumbnail struct {
156+		Path string
157+	}
158+
159 	Pagination struct {
160 		Page int
161 		Size int
162@@ -52,10 +56,15 @@ 		Exists(context.Context, string) (bool, error)
163 		List(context.Context, *Pagination) ([]*Media, error)
164 		Get(context.Context, string) (*Media, error)
165 		GetPath(context.Context, string) (string, error)
166+		GetThumbnailPath(context.Context, string) (string, error)
167 
168-		GetEmptyEXIF(context.Context, *Pagination) ([]*Media, error)
169+		ListEmptyEXIF(context.Context, *Pagination) ([]*Media, error)
170 		GetEXIF(context.Context, uint) (*MediaEXIF, error)
171 		CreateEXIF(context.Context, uint, *MediaEXIF) error
172+
173+		ListEmptyThumbnail(context.Context, *Pagination) ([]*Media, error)
174+		GetThumbnail(context.Context, uint) (*MediaThumbnail, error)
175+		CreateThumbnail(context.Context, uint, *MediaThumbnail) error
176 	}
177 )
178 
179diff --git a/pkg/database/sql/media.go b/pkg/database/sql/media.go
180index 27f8cf0f642ad82c9b6bec7480d0d31ada616ab8..b8203f382fb83a92aa9d8447afda5918188166af 100644
181--- a/pkg/database/sql/media.go
182+++ b/pkg/database/sql/media.go
183@@ -41,6 +41,13 @@ 		GPSLatitude     *float64
184 		GPSLongitude    *float64
185 	}
186 
187+	MediaThumbnail struct {
188+		gorm.Model
189+		Path    string
190+		MediaID uint
191+		Media   Media
192+	}
193+
194 	MediaRepository struct {
195 		db *gorm.DB
196 	}
197@@ -76,6 +83,12 @@ 		Orientation:     m.Orientation,
198 		ExposureProgram: m.ExposureProgram,
199 		GPSLatitude:     m.GPSLatitude,
200 		GPSLongitude:    m.GPSLongitude,
201+	}
202+}
203+
204+func (m *MediaThumbnail) ToModel() *repository.MediaThumbnail {
205+	return &repository.MediaThumbnail{
206+		Path: m.Path,
207 	}
208 }
209 
210@@ -173,6 +186,24 @@
211 	return path, nil
212 }
213 
214+func (self *MediaRepository) GetThumbnailPath(ctx context.Context, pathHash string) (string, error) {
215+	var path string
216+	result := self.db.
217+		WithContext(ctx).
218+		Model(&Media{}).
219+		Select("media_thumbnails.path").
220+		Joins("left join media_thumbnails on media.id = media_thumbnails.media_id").
221+		Where("media.path_hash = ?", pathHash).
222+		Limit(1).
223+		Find(&path)
224+
225+	if result.Error != nil {
226+		return "", result.Error
227+	}
228+
229+	return path, nil
230+}
231+
232 func (m *MediaRepository) GetEXIF(ctx context.Context, mediaID uint) (*repository.MediaEXIF, error) {
233 	exif := &MediaEXIF{}
234 	result := m.db.
235@@ -220,7 +251,7 @@
236 	return nil
237 }
238 
239-func (r *MediaRepository) GetEmptyEXIF(ctx context.Context, pagination *repository.Pagination) ([]*repository.Media, error) {
240+func (r *MediaRepository) ListEmptyEXIF(ctx context.Context, pagination *repository.Pagination) ([]*repository.Media, error) {
241 	medias := make([]*Media, 0)
242 	result := r.db.
243 		WithContext(ctx).
244@@ -242,3 +273,58 @@ 	})
245 
246 	return m, nil
247 }
248+
249+func (r *MediaRepository) ListEmptyThumbnail(ctx context.Context, pagination *repository.Pagination) ([]*repository.Media, error) {
250+	medias := make([]*Media, 0)
251+	result := r.db.
252+		WithContext(ctx).
253+		Model(&Media{}).
254+		Joins("left join media_thumbnails on media.id = media_thumbnails.media_id").
255+		Where("media_thumbnails.media_id IS NULL").
256+		Offset(pagination.Page * pagination.Size).
257+		Limit(pagination.Size).
258+		Order("media.created_at DESC").
259+		Find(&medias)
260+
261+	if result.Error != nil {
262+		return nil, result.Error
263+	}
264+
265+	m := list.Map(medias, func(s *Media) *repository.Media {
266+		return s.ToModel()
267+	})
268+
269+	return m, nil
270+}
271+
272+func (m *MediaRepository) GetThumbnail(ctx context.Context, mediaID uint) (*repository.MediaThumbnail, error) {
273+	thumbnail := &MediaThumbnail{}
274+	result := m.db.
275+		WithContext(ctx).
276+		Model(&Media{}).
277+		Where("media_id = ?", mediaID).
278+		Limit(1).
279+		Take(m)
280+
281+	if result.Error != nil {
282+		return nil, result.Error
283+	}
284+
285+	return thumbnail.ToModel(), nil
286+}
287+
288+func (m *MediaRepository) CreateThumbnail(ctx context.Context, mediaID uint, thumbnail *repository.MediaThumbnail) error {
289+	media := &MediaThumbnail{
290+		MediaID: mediaID,
291+		Path:    thumbnail.Path,
292+	}
293+
294+	result := m.db.
295+		WithContext(ctx).
296+		Create(media)
297+	if result.Error != nil {
298+		return result.Error
299+	}
300+
301+	return nil
302+}
303diff --git a/pkg/database/sql/migration.go b/pkg/database/sql/migration.go
304index 019eb91371ca24ec8f1b55fa1e6b80730c716ac0..076bf69da597bead7bd132bbad5473ad73c6be4e 100644
305--- a/pkg/database/sql/migration.go
306+++ b/pkg/database/sql/migration.go
307@@ -8,6 +8,7 @@ 		&User{},
308 		&Settings{},
309 		&Media{},
310 		&MediaEXIF{},
311+		&MediaThumbnail{},
312 	} {
313 		if err := db.AutoMigrate(m); err != nil {
314 			return err
315diff --git a/pkg/fileop/file.go b/pkg/fileop/file.go
316new file mode 100644
317index 0000000000000000000000000000000000000000..07c08e5d57eff695d8658ae7626adfb132e7f4d3
318--- /dev/null
319+++ b/pkg/fileop/file.go
320@@ -0,0 +1,17 @@
321+package fileop
322+
323+import (
324+	"crypto/md5"
325+	"encoding/hex"
326+	"strings"
327+)
328+
329+func GetHashFromPath(path string) string {
330+	hash := md5.Sum([]byte(path))
331+	return hex.EncodeToString(hash[:])
332+}
333+
334+func IsMimeTypeSupported(mimetype string) bool {
335+	return strings.HasPrefix(mimetype, "video") &&
336+		strings.HasPrefix(mimetype, "image")
337+}
338diff --git a/pkg/fileop/thumbnail.go b/pkg/fileop/thumbnail.go
339new file mode 100644
340index 0000000000000000000000000000000000000000..32f6064dc93654e14514875551c2c452fbe1d773
341--- /dev/null
342+++ b/pkg/fileop/thumbnail.go
343@@ -0,0 +1,60 @@
344+package fileop
345+
346+import (
347+	"image"
348+	"image/jpeg"
349+	"os"
350+	"os/exec"
351+
352+	"github.com/disintegration/imaging"
353+)
354+
355+func EncodeImageThumbnail(inputPath string, outputPath string, width, height int) error {
356+	inputImage, err := imaging.Open(inputPath, imaging.AutoOrientation(true))
357+	if err != nil {
358+		return err
359+	}
360+
361+	thumbImage := imaging.Fit(inputImage, width, height, imaging.Lanczos)
362+	if err = encodeImageJPEG(thumbImage, outputPath, 60); err != nil {
363+		return err
364+	}
365+
366+	return nil
367+}
368+
369+func encodeImageJPEG(image image.Image, outputPath string, jpegQuality int) error {
370+	photo_file, err := os.Create(outputPath)
371+	if err != nil {
372+		return err
373+	}
374+	defer photo_file.Close()
375+
376+	err = jpeg.Encode(photo_file, image, &jpeg.Options{Quality: jpegQuality})
377+	if err != nil {
378+		return err
379+	}
380+
381+	return nil
382+}
383+
384+func EncodeVideoThumbnail(inputPath string, outputPath string, width, height int) error {
385+	args := []string{
386+		"-i",
387+		inputPath,
388+		"-vframes", "1", // output one frame
389+		"-an", // disable audio
390+		"-vf", "scale='min(1024,iw)':'min(1024,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
391+		"-vf", "select=gte(n\\,100)",
392+		outputPath,
393+	}
394+
395+	cmd := exec.Command("ffmpeg", args...)
396+
397+	if err := cmd.Run(); err != nil {
398+		return err
399+	}
400+
401+	return nil
402+
403+}
404diff --git a/pkg/view/media.go b/pkg/view/media.go
405index ce9e27278588eb37a883deda70eaa4b6aa4edc9c..0b588f4f4c52b7aff77b48979490d43e703b95f8 100644
406--- a/pkg/view/media.go
407+++ b/pkg/view/media.go
408@@ -93,9 +93,23 @@ 	fasthttp.ServeFileUncompressed(ctx, media.Path)
409 	return nil
410 }
411 
412+func (self *MediaView) GetThumbnail(ctx *fasthttp.RequestCtx) error {
413+	pathHash := string(ctx.FormValue("path_hash"))
414+
415+	path, err := self.mediaRepository.GetThumbnailPath(ctx, pathHash)
416+	if err != nil {
417+		return self.GetImage(ctx)
418+	}
419+
420+	ctx.Request.Header.SetContentType("image/jpeg")
421+	fasthttp.ServeFileUncompressed(ctx, path)
422+	return nil
423+}
424+
425 func (self *MediaView) SetMyselfIn(r *ext.Router) {
426 	r.GET("/media", self.Index)
427 	r.POST("/media", self.Index)
428 
429 	r.GET("/media/image", self.GetImage)
430+	r.GET("/media/thumbnail", self.GetThumbnail)
431 }
432diff --git a/pkg/worker/exif_scanner.go b/pkg/worker/exif_scanner.go
433index 97790a083761672e3d03680d8e32be2eec902368..5ea18104d9be7dec5448e46321bc603d8d142e94 100644
434--- a/pkg/worker/exif_scanner.go
435+++ b/pkg/worker/exif_scanner.go
436@@ -23,15 +23,10 @@ 	}
437 }
438 
439 func (e *EXIFScanner) Query(ctx context.Context) ([]*repository.Media, error) {
440-	medias, err := e.repository.GetEmptyEXIF(ctx, &repository.Pagination{
441+	return e.repository.ListEmptyEXIF(ctx, &repository.Pagination{
442 		Page: 0,
443 		Size: 100,
444 	})
445-	if err != nil {
446-		return nil, err
447-	}
448-
449-	return medias, nil
450 }
451 
452 func (e *EXIFScanner) Process(ctx context.Context, m *repository.Media) error {
453diff --git a/pkg/worker/file_scanner.go b/pkg/worker/file_scanner.go
454index aa79035b77c5fb9724a149bbb0e29450583f0baa..b4f907ab682473e1082d0694b690c5a25a75c3a2 100644
455--- a/pkg/worker/file_scanner.go
456+++ b/pkg/worker/file_scanner.go
457@@ -2,14 +2,12 @@ package worker
458 
459 import (
460 	"context"
461-	"crypto/md5"
462-	"encoding/hex"
463 	"io/fs"
464 	"mime"
465 	"path/filepath"
466-	"strings"
467 
468 	"git.sr.ht/~gabrielgio/img/pkg/database/repository"
469+	"git.sr.ht/~gabrielgio/img/pkg/fileop"
470 )
471 
472 type (
473@@ -59,18 +57,17 @@ 	return c, nil
474 }
475 
476 func (f *FileScanner) Process(ctx context.Context, path string) error {
477-	m := mime.TypeByExtension(filepath.Ext(path))
478-	if !strings.HasPrefix(m, "video") && !strings.HasPrefix(m, "image") {
479+	mimetype := mime.TypeByExtension(filepath.Ext(path))
480+	supported := fileop.IsMimeTypeSupported(mimetype)
481+	if !supported {
482 		return nil
483 	}
484 
485-	hash := md5.Sum([]byte(path))
486-	str := hex.EncodeToString(hash[:])
487-	name := filepath.Base(path)
488+	hash := fileop.GetHashFromPath(path)
489 
490-	exists, errResp := f.repository.Exists(ctx, str)
491-	if errResp != nil {
492-		return errResp
493+	exists, err := f.repository.Exists(ctx, hash)
494+	if err != nil {
495+		return err
496 	}
497 
498 	if exists {
499@@ -78,9 +75,9 @@ 		return nil
500 	}
501 
502 	return f.repository.Create(ctx, &repository.CreateMedia{
503-		Name:     name,
504+		Name:     filepath.Base(path),
505 		Path:     path,
506-		PathHash: str,
507-		MIMEType: m,
508+		PathHash: hash,
509+		MIMEType: mimetype,
510 	})
511 }
512diff --git a/pkg/worker/thumbnail_scanner.go b/pkg/worker/thumbnail_scanner.go
513new file mode 100644
514index 0000000000000000000000000000000000000000..cc201b830ff9d562380baa44cf80a2926cad9619
515--- /dev/null
516+++ b/pkg/worker/thumbnail_scanner.go
517@@ -0,0 +1,62 @@
518+package worker
519+
520+import (
521+	"context"
522+	"math"
523+	"os"
524+	"path"
525+
526+	"git.sr.ht/~gabrielgio/img/pkg/database/repository"
527+	"git.sr.ht/~gabrielgio/img/pkg/fileop"
528+)
529+
530+type (
531+	ThumbnailScanner struct {
532+		repository repository.MediaRepository
533+		cachePath  string
534+	}
535+)
536+
537+var _ BatchProcessor[*repository.Media] = &EXIFScanner{}
538+
539+func NewThumbnailScanner(cachePath string, repository repository.MediaRepository) *ThumbnailScanner {
540+	return &ThumbnailScanner{
541+		repository: repository,
542+		cachePath:  cachePath,
543+	}
544+}
545+
546+func (t *ThumbnailScanner) Query(ctx context.Context) ([]*repository.Media, error) {
547+	return t.repository.ListEmptyThumbnail(ctx, &repository.Pagination{
548+		Page: 0,
549+		Size: 100,
550+	})
551+}
552+
553+func (t *ThumbnailScanner) Process(ctx context.Context, media *repository.Media) error {
554+	split := media.PathHash[:2]
555+	filename := media.PathHash[2:]
556+	folder := path.Join(t.cachePath, split)
557+	output := path.Join(folder, filename+".jpeg")
558+
559+	err := os.MkdirAll(folder, os.ModePerm)
560+	if err != nil {
561+		return err
562+	}
563+
564+	if media.IsVideo() {
565+		err := fileop.EncodeVideoThumbnail(media.Path, output, 1080, 1080)
566+		if err != nil {
567+			return err
568+		}
569+	} else {
570+		err := fileop.EncodeImageThumbnail(media.Path, output, 1080, math.MaxInt)
571+		if err != nil {
572+			return err
573+		}
574+	}
575+
576+	return t.repository.CreateThumbnail(ctx, media.ID, &repository.MediaThumbnail{
577+		Path: output,
578+	})
579+}
580diff --git a/templates/media.html b/templates/media.html
581index 6302a573451a953b2a02275611a63377930d0db3..188d5b4c2be930218a1ad0f7b5527c53052fc3db 100644
582--- a/templates/media.html
583+++ b/templates/media.html
584@@ -5,13 +5,13 @@ <div class="columns is-multiline">
585 {{range .Data.Medias}}
586 <div class="card">
587     <div class="card-image">
588-        {{ if .IsVideo }}
589-        <video controls muted="true" preload="metadata">
590-            <source src="/media/image?path_hash={{.PathHash}}" type="{{.MIMEType}}">
591-        </video>
592-        {{ else }}
593+       {{ if .IsVideo }}
594+       <video controls muted="true" poster="/media/thumbnail?path_hash={{.PathHash}}" preload="none">
595+           <source src="/media/image?path_hash={{.PathHash}}" type="{{.MIMEType}}">
596+       </video>
597+       {{ else }}
598         <figure class="image is-fit">
599-            <img src="/media/image?path_hash={{.PathHash}}">
600+            <img src="/media/thumbnail?path_hash={{.PathHash}}">
601         </figure>
602         {{ end }}
603     </div>