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>