diff --git a/internal/api/album.go b/internal/api/album.go index 8308a3930..95f156fe3 100644 --- a/internal/api/album.go +++ b/internal/api/album.go @@ -12,9 +12,11 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/rnd" + "github.com/photoprism/photoprism/internal/thumb" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" @@ -339,7 +341,7 @@ func DownloadAlbum(router *gin.RouterGroup, conf *config.Config) { } zipPath := path.Join(conf.ExportPath(), "album") - zipToken := util.RandomToken(3) + zipToken := rnd.Token(3) zipBaseName := fmt.Sprintf("%s-%s.zip", strings.Title(a.AlbumSlug), zipToken) zipFileName := path.Join(zipPath, zipBaseName) @@ -362,21 +364,21 @@ func DownloadAlbum(router *gin.RouterGroup, conf *config.Config) { zipWriter := zip.NewWriter(newZipFile) defer zipWriter.Close() - for _, file := range p { - fileName := path.Join(conf.OriginalsPath(), file.FileName) - fileAlias := file.DownloadFileName() + for _, f := range p { + fileName := path.Join(conf.OriginalsPath(), f.FileName) + fileAlias := f.DownloadFileName() - if util.Exists(fileName) { + if file.Exists(fileName) { if err := addFileToZip(zipWriter, fileName, fileAlias); err != nil { log.Error(err) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": util.UcFirst("failed to create zip file")}) return } - log.Infof("album: added \"%s\" as \"%s\"", file.FileName, fileAlias) + log.Infof("album: added \"%s\" as \"%s\"", f.FileName, fileAlias) } else { - log.Warnf("album: \"%s\" is missing", file.FileName) - file.FileMissing = true - conf.Db().Save(&file) + log.Warnf("album: \"%s\" is missing", f.FileName) + f.FileMissing = true + conf.Db().Save(&f) } } @@ -385,7 +387,7 @@ func DownloadAlbum(router *gin.RouterGroup, conf *config.Config) { zipWriter.Close() newZipFile.Close() - if !util.Exists(zipFileName) { + if !file.Exists(zipFileName) { log.Errorf("could not find zip file: %s", zipFileName) c.Data(404, "image/svg+xml", photoIconSvg) return @@ -411,7 +413,7 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) { typeName := c.Param("type") uuid := c.Param("uuid") - thumbType, ok := photoprism.ThumbnailTypes[typeName] + thumbType, ok := thumb.Types[typeName] if !ok { log.Errorf("invalid type: %s", typeName) @@ -421,7 +423,7 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) { q := query.New(conf.OriginalsPath(), conf.Db()) - file, err := q.FindAlbumThumbByUUID(uuid) + f, err := q.FindAlbumThumbByUUID(uuid) if err != nil { log.Debugf("album has no photos yet, using generic thumb image: %s", uuid) @@ -429,21 +431,21 @@ func AlbumThumbnail(router *gin.RouterGroup, conf *config.Config) { return } - fileName := path.Join(conf.OriginalsPath(), file.FileName) + fileName := path.Join(conf.OriginalsPath(), f.FileName) - if !util.Exists(fileName) { + if !file.Exists(fileName) { log.Errorf("could not find original for thumbnail: %s", fileName) c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg) // Set missing flag so that the file doesn't show up in search results anymore - file.FileMissing = true - conf.Db().Save(&file) + f.FileMissing = true + conf.Db().Save(&f) return } - if thumbnail, err := photoprism.ThumbnailFromFile(fileName, file.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil { + if thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil { if c.Query("download") != "" { - downloadFileName := file.DownloadFileName() + downloadFileName := f.DownloadFileName() c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFileName)) } diff --git a/internal/api/batch.go b/internal/api/batch.go index 907e8b919..aaa85b9a9 100644 --- a/internal/api/batch.go +++ b/internal/api/batch.go @@ -90,7 +90,6 @@ func BatchPhotosRestore(router *gin.RouterGroup, conf *config.Config) { }) } - // POST /api/v1/batch/albums/delete func BatchAlbumsDelete(router *gin.RouterGroup, conf *config.Config) { router.POST("/batch/albums/delete", func(c *gin.Context) { diff --git a/internal/api/download.go b/internal/api/download.go index b9e452282..2409a876d 100644 --- a/internal/api/download.go +++ b/internal/api/download.go @@ -5,8 +5,8 @@ import ( "path" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/internal/util" "github.com/gin-gonic/gin" ) @@ -24,26 +24,26 @@ func GetDownload(router *gin.RouterGroup, conf *config.Config) { fileHash := c.Param("hash") q := query.New(conf.OriginalsPath(), conf.Db()) - file, err := q.FindFileByHash(fileHash) + f, err := q.FindFileByHash(fileHash) if err != nil { c.AbortWithStatusJSON(404, gin.H{"error": err.Error()}) return } - fileName := path.Join(conf.OriginalsPath(), file.FileName) + fileName := path.Join(conf.OriginalsPath(), f.FileName) - if !util.Exists(fileName) { + if !file.Exists(fileName) { log.Errorf("could not find original: %s", fileHash) c.Data(404, "image/svg+xml", photoIconSvg) // Set missing flag so that the file doesn't show up in search results anymore - file.FileMissing = true - conf.Db().Save(&file) + f.FileMissing = true + conf.Db().Save(&f) return } - downloadFileName := file.DownloadFileName() + downloadFileName := f.DownloadFileName() c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFileName)) diff --git a/internal/api/import.go b/internal/api/import.go index 459ad83bd..372384f3d 100644 --- a/internal/api/import.go +++ b/internal/api/import.go @@ -8,11 +8,10 @@ import ( "strings" "time" + "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/event" - "github.com/photoprism/photoprism/internal/util" - - "github.com/gin-gonic/gin" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/photoprism" ) @@ -61,7 +60,7 @@ func StartImport(router *gin.RouterGroup, conf *config.Config) { imp.Start(path) - if subPath != "" && path != conf.ImportPath() && util.DirectoryIsEmpty(path) { + if subPath != "" && path != conf.ImportPath() && file.IsEmpty(path) { if err := os.Remove(path); err != nil { log.Errorf("import: could not deleted empty directory \"%s\": %s", path, err) } else { diff --git a/internal/api/index.go b/internal/api/index.go index fa65e038f..225c3906d 100644 --- a/internal/api/index.go +++ b/internal/api/index.go @@ -55,11 +55,6 @@ func StartIndexing(router *gin.RouterGroup, conf *config.Config) { return } - // Set thumbnails JPEG quality and size - photoprism.JpegQuality = conf.ThumbQuality() - photoprism.MaxThumbWidth = conf.ThumbSize() - photoprism.MaxThumbHeight = conf.ThumbSize() - path := conf.OriginalsPath() event.Info(fmt.Sprintf("indexing photos in \"%s\"", filepath.Base(path))) diff --git a/internal/api/label.go b/internal/api/label.go index 1b2ea8d7d..75611c4e2 100644 --- a/internal/api/label.go +++ b/internal/api/label.go @@ -12,9 +12,10 @@ import ( "github.com/gin-gonic/gin/binding" "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/util" ) @@ -123,7 +124,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) { labelUUID := c.Param("uuid") start := time.Now() - thumbType, ok := photoprism.ThumbnailTypes[typeName] + thumbType, ok := thumb.Types[typeName] if !ok { log.Errorf("invalid type: %s", typeName) @@ -142,7 +143,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) { return } - file, err := q.FindLabelThumbByUUID(labelUUID) + f, err := q.FindLabelThumbByUUID(labelUUID) if err != nil { log.Errorf(err.Error()) @@ -150,19 +151,19 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) { return } - fileName := path.Join(conf.OriginalsPath(), file.FileName) + fileName := path.Join(conf.OriginalsPath(), f.FileName) - if !util.Exists(fileName) { + if !file.Exists(fileName) { log.Errorf("could not find original for thumbnail: %s", fileName) c.Data(http.StatusOK, "image/svg+xml", labelIconSvg) // Set missing flag so that the file doesn't show up in search results anymore - file.FileMissing = true - conf.Db().Save(&file) + f.FileMissing = true + conf.Db().Save(&f) return } - if thumbnail, err := photoprism.ThumbnailFromFile(fileName, file.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil { + if thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil { thumbData, err := ioutil.ReadFile(thumbnail) if err != nil { diff --git a/internal/api/photo.go b/internal/api/photo.go index 83fa7a819..293659161 100644 --- a/internal/api/photo.go +++ b/internal/api/photo.go @@ -7,6 +7,7 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/util" @@ -73,26 +74,26 @@ func UpdatePhoto(router *gin.RouterGroup, conf *config.Config) { func GetPhotoDownload(router *gin.RouterGroup, conf *config.Config) { router.GET("/photos/:uuid/download", func(c *gin.Context) { q := query.New(conf.OriginalsPath(), conf.Db()) - file, err := q.FindFileByPhotoUUID(c.Param("uuid")) + f, err := q.FindFileByPhotoUUID(c.Param("uuid")) if err != nil { c.AbortWithStatusJSON(404, gin.H{"error": err.Error()}) return } - fileName := path.Join(conf.OriginalsPath(), file.FileName) + fileName := path.Join(conf.OriginalsPath(), f.FileName) - if !util.Exists(fileName) { + if !file.Exists(fileName) { log.Errorf("could not find original: %s", c.Param("uuid")) c.Data(404, "image/svg+xml", photoIconSvg) // Set missing flag so that the file doesn't show up in search results anymore - file.FileMissing = true - conf.Db().Save(&file) + f.FileMissing = true + conf.Db().Save(&f) return } - downloadFileName := file.DownloadFileName() + downloadFileName := f.DownloadFileName() c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFileName)) diff --git a/internal/api/photo_thumbnail.go b/internal/api/photo_thumbnail.go index a8927b9c5..e0fea45ec 100644 --- a/internal/api/photo_thumbnail.go +++ b/internal/api/photo_thumbnail.go @@ -5,12 +5,11 @@ import ( "net/http" "path" - "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/internal/util" - "github.com/gin-gonic/gin" - "github.com/photoprism/photoprism/internal/photoprism" + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/file" + "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/thumb" ) // GET /api/v1/thumbnails/:hash/:type @@ -23,7 +22,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) { fileHash := c.Param("hash") typeName := c.Param("type") - thumbType, ok := photoprism.ThumbnailTypes[typeName] + thumbType, ok := thumb.Types[typeName] if !ok { log.Errorf("invalid type: %s", typeName) @@ -32,28 +31,28 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) { } q := query.New(conf.OriginalsPath(), conf.Db()) - file, err := q.FindFileByHash(fileHash) + f, err := q.FindFileByHash(fileHash) if err != nil { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - fileName := path.Join(conf.OriginalsPath(), file.FileName) + fileName := path.Join(conf.OriginalsPath(), f.FileName) - if !util.Exists(fileName) { + if !file.Exists(fileName) { log.Errorf("could not find original for thumbnail: %s", fileName) c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg) // Set missing flag so that the file doesn't show up in search results anymore - file.FileMissing = true - conf.Db().Save(&file) + f.FileMissing = true + conf.Db().Save(&f) return } - if thumbnail, err := photoprism.ThumbnailFromFile(fileName, file.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil { + if thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...); err == nil { if c.Query("download") != "" { - downloadFileName := file.DownloadFileName() + downloadFileName := f.DownloadFileName() c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", downloadFileName)) } diff --git a/internal/api/preview.go b/internal/api/preview.go index edf069871..5d9e2a179 100644 --- a/internal/api/preview.go +++ b/internal/api/preview.go @@ -12,10 +12,10 @@ import ( "github.com/disintegration/imaging" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/internal/photoprism" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/thumb" ) // GET /api/v1/preview @@ -33,7 +33,7 @@ func GetPreview(router *gin.RouterGroup, conf *config.Config) { previewFilename := fmt.Sprintf("%s/%s.jpg", thumbPath, t[6:8]) - if util.Exists(previewFilename) { + if file.Exists(previewFilename) { c.File(previewFilename) return } @@ -60,22 +60,22 @@ func GetPreview(router *gin.RouterGroup, conf *config.Config) { y := 0 preview := imaging.New(width, height, color.NRGBA{255, 255, 255, 255}) - thumbType, _ := photoprism.ThumbnailTypes["tile_224"] + thumbType, _ := thumb.Types["tile_224"] - for _, file := range p { - fileName := path.Join(conf.OriginalsPath(), file.FileName) + for _, f := range p { + fileName := path.Join(conf.OriginalsPath(), f.FileName) - if !util.Exists(fileName) { + if !file.Exists(fileName) { log.Errorf("could not find original for thumbnail: %s", fileName) c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg) // Set missing flag so that the file doesn't show up in search results anymore - file.FileMissing = true - conf.Db().Save(&file) + f.FileMissing = true + conf.Db().Save(&f) return } - thumbnail, err := photoprism.ThumbnailFromFile(fileName, file.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...) + thumbnail, err := thumb.FromFile(fileName, f.FileHash, conf.ThumbnailsPath(), thumbType.Width, thumbType.Height, thumbType.Options...) if err != nil { log.Error(err) diff --git a/internal/api/zip.go b/internal/api/zip.go index 8c702cf45..11c9c9ce9 100644 --- a/internal/api/zip.go +++ b/internal/api/zip.go @@ -11,8 +11,10 @@ import ( "time" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/query" + "github.com/photoprism/photoprism/internal/rnd" "github.com/photoprism/photoprism/internal/util" "github.com/gin-gonic/gin" @@ -44,7 +46,7 @@ func CreateZip(router *gin.RouterGroup, conf *config.Config) { } zipPath := path.Join(conf.ExportPath(), "zip") - zipToken := util.RandomToken(3) + zipToken := rnd.Token(3) zipYear := time.Now().Format("January-2006") zipBaseName := fmt.Sprintf("Photos-%s-%s.zip", zipYear, zipToken) zipFileName := path.Join(zipPath, zipBaseName) @@ -68,21 +70,21 @@ func CreateZip(router *gin.RouterGroup, conf *config.Config) { zipWriter := zip.NewWriter(newZipFile) defer zipWriter.Close() - for _, file := range files { - fileName := path.Join(conf.OriginalsPath(), file.FileName) - fileAlias := file.DownloadFileName() + for _, f := range files { + fileName := path.Join(conf.OriginalsPath(), f.FileName) + fileAlias := f.DownloadFileName() - if util.Exists(fileName) { + if file.Exists(fileName) { if err := addFileToZip(zipWriter, fileName, fileAlias); err != nil { log.Error(err) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": util.UcFirst("failed to create zip file")}) return } - log.Infof("zip: added \"%s\" as \"%s\"", file.FileName, fileAlias) + log.Infof("zip: added \"%s\" as \"%s\"", f.FileName, fileAlias) } else { - log.Warnf("zip: \"%s\" is missing", file.FileName) - file.FileMissing = true - conf.Db().Save(&file) + log.Warnf("zip: \"%s\" is missing", f.FileName) + f.FileMissing = true + conf.Db().Save(&f) } } @@ -103,7 +105,7 @@ func DownloadZip(router *gin.RouterGroup, conf *config.Config) { c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", zipBaseName)) - if !util.Exists(zipFileName) { + if !file.Exists(zipFileName) { log.Errorf("could not find zip file: %s", zipFileName) c.Data(404, "image/svg+xml", photoIconSvg) return diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 591f69b59..a93ec147e 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -12,14 +12,14 @@ import ( "syscall" "github.com/photoprism/photoprism/internal/event" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" "github.com/sevlyar/go-daemon" ) var log = event.Log func childAlreadyRunning(filePath string) (pid int, running bool) { - if !util.Exists(filePath) { + if !file.Exists(filePath) { return pid, false } diff --git a/internal/commands/start.go b/internal/commands/start.go index ba669d6f6..1b0e83ef4 100644 --- a/internal/commands/start.go +++ b/internal/commands/start.go @@ -10,8 +10,8 @@ import ( "time" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/server" - "github.com/photoprism/photoprism/internal/util" "github.com/sevlyar/go-daemon" "github.com/urfave/cli" ) @@ -96,7 +96,7 @@ func startAction(ctx *cli.Context) error { } if child != nil { - if !util.Overwrite(conf.PIDFilename(), []byte(strconv.Itoa(child.Pid))) { + if !file.Overwrite(conf.PIDFilename(), []byte(strconv.Itoa(child.Pid))) { log.Fatalf("failed writing process id to \"%s\"", conf.PIDFilename()) } diff --git a/internal/commands/thumbnails.go b/internal/commands/thumbnails.go index d5e59637e..96b598c55 100644 --- a/internal/commands/thumbnails.go +++ b/internal/commands/thumbnails.go @@ -32,10 +32,6 @@ func thumbnailsAction(ctx *cli.Context) error { log.Infof("creating thumbnails in \"%s\"", conf.ThumbnailsPath()) - photoprism.JpegQuality = conf.ThumbQuality() - photoprism.MaxThumbWidth = conf.ThumbSize() - photoprism.MaxThumbHeight = conf.ThumbSize() - if err := photoprism.CreateThumbnailsFromOriginals(conf.OriginalsPath(), conf.ThumbnailsPath(), ctx.Bool("force")); err != nil { log.Error(err) return err diff --git a/internal/config/client.go b/internal/config/client.go index fba57633a..b4e0cb966 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -6,7 +6,7 @@ import ( "github.com/photoprism/photoprism/internal/colors" "github.com/photoprism/photoprism/internal/entity" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" ) // HTTP client / Web UI config values @@ -118,8 +118,8 @@ func (c *Config) ClientConfig() ClientConfig { categories[i].Title = strings.Title(l.LabelName) } - jsHash := util.Hash(c.HttpStaticBuildPath() + "/app.js") - cssHash := util.Hash(c.HttpStaticBuildPath() + "/app.css") + jsHash := file.Hash(c.HttpStaticBuildPath() + "/app.js") + cssHash := file.Hash(c.HttpStaticBuildPath() + "/app.css") // Feature Flags var flags []string diff --git a/internal/config/config.go b/internal/config/config.go index 8cff3f136..c9312a08f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( _ "github.com/jinzhu/gorm/dialects/sqlite" gc "github.com/patrickmn/go-cache" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/thumb" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) @@ -22,6 +23,15 @@ type Config struct { config *Params } +func init() { + for name, t := range thumb.Types { + if t.Public { + thumbnail := Thumbnail{Name: name, Width: t.Width, Height: t.Height} + Thumbnails = append(Thumbnails, thumbnail) + } + } +} + func initLogger(debug bool) { log.SetFormatter(&logrus.TextFormatter{ DisableColors: false, @@ -44,6 +54,10 @@ func NewConfig(ctx *cli.Context) *Config { log.SetLevel(c.LogLevel()) + thumb.JpegQuality = c.ThumbQuality() + thumb.MaxWidth = c.ThumbSize() + thumb.MaxHeight = c.ThumbSize() + return c } @@ -180,17 +194,32 @@ func (c *Config) Workers() int { return runtime.NumCPU() } -// ThumbQuality returns the thumbnail jpeg quality setting (0-100). +// ThumbQuality returns the thumbnail jpeg quality setting (25-100). func (c *Config) ThumbQuality() int { + if c.config.ThumbQuality > 100 { + return 100 + } + + if c.config.ThumbQuality < 25 { + return 25 + } + return c.config.ThumbQuality } -// ThumbSize returns the thumbnail size limit in pixels. +// ThumbSize returns the thumbnail size limit in pixels (720-16384). func (c *Config) ThumbSize() int { + if c.config.ThumbSize > 16384 { + return 16384 + } + + if c.config.ThumbSize < 720 { + return 720 + } + return c.config.ThumbSize } - // GeoCodingApi returns the preferred geo coding api (none, osm or places). func (c *Config) GeoCodingApi() string { switch c.config.GeoCodingApi { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3f90b259b..c3894b682 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,7 +3,7 @@ package config import ( "testing" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" "github.com/stretchr/testify/assert" ) @@ -17,7 +17,7 @@ func TestNewConfig(t *testing.T) { assert.IsType(t, new(Config), c) - assert.Equal(t, util.ExpandedFilename("../../assets"), c.AssetsPath()) + assert.Equal(t, file.ExpandFilename("../../assets"), c.AssetsPath()) assert.False(t, c.Debug()) assert.False(t, c.ReadOnly()) } diff --git a/internal/config/db.go b/internal/config/db.go index 6becf8b72..cf325ded3 100644 --- a/internal/config/db.go +++ b/internal/config/db.go @@ -138,6 +138,9 @@ func (c *Config) connectToDatabase(ctx context.Context) error { } } + db.LogMode(false) + db.SetLogger(log) + c.db = db return err } @@ -185,7 +188,7 @@ func (c *Config) ImportSQL(filename string) { continue } - var result struct {} + var result struct{} err := q.Raw(stmt).Scan(&result).Error diff --git a/internal/config/filenames.go b/internal/config/filenames.go index 19210a545..7a34a22fd 100644 --- a/internal/config/filenames.go +++ b/internal/config/filenames.go @@ -5,7 +5,7 @@ import ( "os/exec" "path/filepath" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" ) func findExecutable(configBin, defaultBin string) (result string) { @@ -19,7 +19,7 @@ func findExecutable(configBin, defaultBin string) (result string) { result = path } - if !util.Exists(result) { + if !file.Exists(result) { result = "" } diff --git a/internal/config/flags.go b/internal/config/flags.go index dad52fd6b..e6c6f5fca 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -227,13 +227,13 @@ var GlobalFlags = []cli.Flag{ }, cli.IntFlag{ Name: "thumb-quality, q", - Usage: "jpeg quality of thumbnails (0-100)", + Usage: "jpeg quality of thumbnails (25-100)", Value: 95, EnvVar: "PHOTOPRISM_THUMB_QUALITY", }, cli.IntFlag{ Name: "thumb-size", - Usage: "max thumbnail size in pixels", + Usage: "max thumbnail size in pixels (720-16384)", Value: 8192, EnvVar: "PHOTOPRISM_THUMB_SIZE", }, diff --git a/internal/config/params.go b/internal/config/params.go index 752bded5b..f84b8eac2 100644 --- a/internal/config/params.go +++ b/internal/config/params.go @@ -8,7 +8,7 @@ import ( _ "github.com/jinzhu/gorm/dialects/mysql" _ "github.com/jinzhu/gorm/dialects/sqlite" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" "github.com/urfave/cli" "gopkg.in/yaml.v2" ) @@ -87,7 +87,7 @@ func NewParams(ctx *cli.Context) *Params { c.Name = ctx.App.Name c.Copyright = ctx.App.Copyright c.Version = ctx.App.Version - c.ConfigFile = util.ExpandedFilename(ctx.GlobalString("config-file")) + c.ConfigFile = file.ExpandFilename(ctx.GlobalString("config-file")) if err := c.SetValuesFromFile(c.ConfigFile); err != nil { log.Debug(err) @@ -103,21 +103,21 @@ func NewParams(ctx *cli.Context) *Params { } func (c *Params) expandFilenames() { - c.ConfigPath = util.ExpandedFilename(c.ConfigPath) - c.ResourcesPath = util.ExpandedFilename(c.ResourcesPath) - c.AssetsPath = util.ExpandedFilename(c.AssetsPath) - c.CachePath = util.ExpandedFilename(c.CachePath) - c.OriginalsPath = util.ExpandedFilename(c.OriginalsPath) - c.ImportPath = util.ExpandedFilename(c.ImportPath) - c.ExportPath = util.ExpandedFilename(c.ExportPath) - c.SqlServerPath = util.ExpandedFilename(c.SqlServerPath) - c.PIDFilename = util.ExpandedFilename(c.PIDFilename) - c.LogFilename = util.ExpandedFilename(c.LogFilename) + c.ConfigPath = file.ExpandFilename(c.ConfigPath) + c.ResourcesPath = file.ExpandFilename(c.ResourcesPath) + c.AssetsPath = file.ExpandFilename(c.AssetsPath) + c.CachePath = file.ExpandFilename(c.CachePath) + c.OriginalsPath = file.ExpandFilename(c.OriginalsPath) + c.ImportPath = file.ExpandFilename(c.ImportPath) + c.ExportPath = file.ExpandFilename(c.ExportPath) + c.SqlServerPath = file.ExpandFilename(c.SqlServerPath) + c.PIDFilename = file.ExpandFilename(c.PIDFilename) + c.LogFilename = file.ExpandFilename(c.LogFilename) } // SetValuesFromFile uses a yaml config file to initiate the configuration entity. func (c *Params) SetValuesFromFile(fileName string) error { - if !util.Exists(fileName) { + if !file.Exists(fileName) { return errors.New(fmt.Sprintf("config file not found: \"%s\"", fileName)) } diff --git a/internal/config/params_test.go b/internal/config/params_test.go index c7dafdae1..cd874dac7 100644 --- a/internal/config/params_test.go +++ b/internal/config/params_test.go @@ -3,7 +3,7 @@ package config import ( "testing" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" "github.com/stretchr/testify/assert" ) @@ -17,7 +17,7 @@ func TestNewParams(t *testing.T) { assert.IsType(t, new(Params), c) - assert.Equal(t, util.ExpandedFilename("../../assets"), c.AssetsPath) + assert.Equal(t, file.ExpandFilename("../../assets"), c.AssetsPath) assert.False(t, c.Debug) assert.False(t, c.ReadOnly) } diff --git a/internal/config/settings.go b/internal/config/settings.go index 328ae3a8e..31dfde0ee 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -5,7 +5,7 @@ import ( "io/ioutil" "os" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" "gopkg.in/yaml.v2" ) @@ -20,7 +20,7 @@ func NewSettings() *Settings { // SetValuesFromFile uses a yaml config file to initiate the configuration entity. func (s *Settings) SetValuesFromFile(fileName string) error { - if !util.Exists(fileName) { + if !file.Exists(fileName) { return fmt.Errorf("settings file not found: \"%s\"", fileName) } @@ -35,7 +35,7 @@ func (s *Settings) SetValuesFromFile(fileName string) error { // WriteValuesToFile uses a yaml config file to initiate the configuration entity. func (s *Settings) WriteValuesToFile(fileName string) error { - if !util.Exists(fileName) { + if !file.Exists(fileName) { return fmt.Errorf("settings file not found: \"%s\"", fileName) } diff --git a/internal/config/test.go b/internal/config/test.go index 3301c2a83..6ce132024 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -10,7 +10,8 @@ import ( _ "github.com/jinzhu/gorm/dialects/mysql" _ "github.com/jinzhu/gorm/dialects/sqlite" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" + "github.com/photoprism/photoprism/internal/thumb" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) @@ -29,7 +30,7 @@ func testDataPath(assetsPath string) string { } func NewTestParams() *Params { - assetsPath := util.ExpandedFilename("../../assets") + assetsPath := file.ExpandFilename("../../assets") testDataPath := testDataPath(assetsPath) @@ -52,7 +53,7 @@ func NewTestParams() *Params { } func NewTestParamsError() *Params { - assetsPath := util.ExpandedFilename("../..") + assetsPath := file.ExpandFilename("../..") testDataPath := testDataPath("../../assets") @@ -93,6 +94,10 @@ func NewTestConfig() *Config { c.ImportSQL(c.ExamplesPath() + "/fixtures.sql") + thumb.JpegQuality = c.ThumbQuality() + thumb.MaxWidth = c.ThumbSize() + thumb.MaxHeight = c.ThumbSize() + return c } @@ -140,8 +145,8 @@ func (c *Config) RemoveTestData(t *testing.T) { } func (c *Config) DownloadTestData(t *testing.T) { - if util.Exists(TestDataZip) { - hash := util.Hash(TestDataZip) + if file.Exists(TestDataZip) { + hash := file.Hash(TestDataZip) if hash != TestDataHash { os.Remove(TestDataZip) @@ -149,17 +154,17 @@ func (c *Config) DownloadTestData(t *testing.T) { } } - if !util.Exists(TestDataZip) { + if !file.Exists(TestDataZip) { fmt.Printf("downloading latest test data zip file from %s\n", TestDataURL) - if err := util.Download(TestDataZip, TestDataURL); err != nil { + if err := file.Download(TestDataZip, TestDataURL); err != nil { fmt.Printf("Download failed: %s\n", err.Error()) } } } func (c *Config) UnzipTestData(t *testing.T) { - if _, err := util.Unzip(TestDataZip, testDataPath(c.AssetsPath())); err != nil { + if _, err := file.Unzip(TestDataZip, testDataPath(c.AssetsPath())); err != nil { t.Logf("could not unzip test data: %s\n", err.Error()) } } diff --git a/internal/config/test_test.go b/internal/config/test_test.go index 371303847..83d39a305 100644 --- a/internal/config/test_test.go +++ b/internal/config/test_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/jinzhu/gorm" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" "github.com/stretchr/testify/assert" "github.com/urfave/cli" ) @@ -26,7 +26,7 @@ func TestNewTestParams(t *testing.T) { assert.IsType(t, new(Params), c) - assert.Equal(t, util.ExpandedFilename("../../assets"), c.AssetsPath) + assert.Equal(t, file.ExpandFilename("../../assets"), c.AssetsPath) assert.False(t, c.Debug) } @@ -43,7 +43,7 @@ func TestNewTestParamsError(t *testing.T) { assert.IsType(t, new(Params), c) - assert.Equal(t, util.ExpandedFilename("../.."), c.AssetsPath) + assert.Equal(t, file.ExpandFilename("../.."), c.AssetsPath) assert.Equal(t, "../../assets/testdata/cache", c.CachePath) assert.False(t, c.Debug) } diff --git a/internal/entity/entity.go b/internal/entity/entity.go index 6ba356a46..0514710b5 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -16,7 +16,7 @@ import ( "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/event" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/rnd" ) var log = event.Log @@ -32,7 +32,7 @@ func ID(prefix rune) string { result := make([]byte, 0, 17) result = append(result, byte(prefix)) result = append(result, strconv.FormatInt(time.Now().UTC().Unix(), 36)[0:6]...) - result = append(result, util.RandomToken(10)...) + result = append(result, rnd.Token(10)...) return string(result) } diff --git a/internal/util/file.go b/internal/file/file.go similarity index 81% rename from internal/util/file.go rename to internal/file/file.go index a503759c2..bd020f2f3 100644 --- a/internal/util/file.go +++ b/internal/file/file.go @@ -1,4 +1,11 @@ -package util +/* +This package encapsulates file related constants and functions. + +Additional information can be found in our Developer Guide: + +https://github.com/photoprism/photoprism/wiki +*/ +package file import ( "archive/zip" @@ -9,8 +16,12 @@ import ( "os/user" "path/filepath" "strings" + + "github.com/photoprism/photoprism/internal/event" ) +var log = event.Log + // Returns true if file exists func Exists(filename string) bool { info, err := os.Stat(filename) @@ -30,7 +41,7 @@ func Overwrite(fileName string, data []byte) bool { } // Returns full path; ~ replaced with actual home directory -func ExpandedFilename(filename string) string { +func ExpandFilename(filename string) string { if filename == "" { return "" } @@ -50,32 +61,6 @@ func ExpandedFilename(filename string) string { return result } -// Extract Zip file in destination directory -func Unzip(src, dest string) (fileNames []string, err error) { - r, err := zip.OpenReader(src) - if err != nil { - return fileNames, err - } - - defer r.Close() - - for _, f := range r.File { - // Skip directories like __OSX - if strings.HasPrefix(f.Name, "__") { - continue - } - - fn, err := copyToFile(f, dest) - if err != nil { - return fileNames, err - } - - fileNames = append(fileNames, fn) - } - - return fileNames, nil -} - // copyToFile copies the zip file to destination // if the zip file is a directory, a directory is created at the destination. func copyToFile(f *zip.File, dest string) (fileName string, err error) { @@ -151,7 +136,7 @@ func Download(filepath string, url string) error { return nil } -func DirectoryIsEmpty(path string) bool { +func IsEmpty(path string) bool { f, err := os.Open(path) if err != nil { diff --git a/internal/util/file_test.go b/internal/file/file_test.go similarity index 79% rename from internal/util/file_test.go rename to internal/file/file_test.go index 556430f5c..46c50cdca 100644 --- a/internal/util/file_test.go +++ b/internal/file/file_test.go @@ -1,4 +1,4 @@ -package util +package file import ( "os" @@ -29,19 +29,19 @@ func TestOverwrite(t *testing.T) { func TestExpandedFilename(t *testing.T) { t.Run("test.jpg", func(t *testing.T) { - filename := ExpandedFilename("./testdata/test.jpg") + filename := ExpandFilename("./testdata/test.jpg") assert.Contains(t, filename, "/testdata/test.jpg") assert.IsType(t, "", filename) }) t.Run("empty filename", func(t *testing.T) { - filename := ExpandedFilename("") + filename := ExpandFilename("") assert.Equal(t, "", filename) assert.IsType(t, "", filename) }) t.Run("~ in filename", func(t *testing.T) { usr, _ := user.Current() expected := usr.HomeDir + "/test.jpg" - filename := ExpandedFilename("~/test.jpg") + filename := ExpandFilename("~/test.jpg") assert.Equal(t, expected, filename) assert.IsType(t, "", filename) }) @@ -49,14 +49,14 @@ func TestExpandedFilename(t *testing.T) { func TestDirectoryIsEmpty(t *testing.T) { t.Run("not empty path", func(t *testing.T) { - assert.Equal(t, false, DirectoryIsEmpty("./testdata")) + assert.Equal(t, false, IsEmpty("./testdata")) }) t.Run("not existing path", func(t *testing.T) { - assert.Equal(t, false, DirectoryIsEmpty("./xxx")) + assert.Equal(t, false, IsEmpty("./xxx")) }) t.Run("empty path", func(t *testing.T) { os.Mkdir("./testdata/emptyDir", 0777) defer os.RemoveAll("./testdata/emptyDir") - assert.Equal(t, true, DirectoryIsEmpty("./testdata/emptyDir")) + assert.Equal(t, true, IsEmpty("./testdata/emptyDir")) }) } diff --git a/internal/util/hash.go b/internal/file/hash.go similarity index 96% rename from internal/util/hash.go rename to internal/file/hash.go index 64d061ffc..27f73711d 100644 --- a/internal/util/hash.go +++ b/internal/file/hash.go @@ -1,4 +1,4 @@ -package util +package file import ( "crypto/sha1" diff --git a/internal/util/hash_test.go b/internal/file/hash_test.go similarity index 96% rename from internal/util/hash_test.go rename to internal/file/hash_test.go index 25c7d4f40..d73c30a56 100644 --- a/internal/util/hash_test.go +++ b/internal/file/hash_test.go @@ -1,4 +1,4 @@ -package util +package file import ( "testing" diff --git a/internal/util/mime.go b/internal/file/mime.go similarity index 97% rename from internal/util/mime.go rename to internal/file/mime.go index c727faef7..141347d97 100644 --- a/internal/util/mime.go +++ b/internal/file/mime.go @@ -1,4 +1,4 @@ -package util +package file import ( "net/http" diff --git a/internal/util/mime_test.go b/internal/file/mime_test.go similarity index 73% rename from internal/util/mime_test.go rename to internal/file/mime_test.go index 2ee12d3a7..1a11b6225 100644 --- a/internal/util/mime_test.go +++ b/internal/file/mime_test.go @@ -1,4 +1,4 @@ -package util +package file import ( "testing" @@ -8,12 +8,12 @@ import ( func TestMimeType(t *testing.T) { t.Run("jpg", func(t *testing.T) { - filename := ExpandedFilename("./testdata/test.jpg") + filename := ExpandFilename("./testdata/test.jpg") mimeType := MimeType(filename) assert.Equal(t, "image/jpeg", mimeType) }) t.Run("not existing filename", func(t *testing.T) { - filename := ExpandedFilename("./testdata/xxx.jpg") + filename := ExpandFilename("./testdata/xxx.jpg") mimeType := MimeType(filename) assert.Equal(t, "", mimeType) }) diff --git a/internal/file/testdata/test.jpg b/internal/file/testdata/test.jpg new file mode 100644 index 000000000..0c8c04edb Binary files /dev/null and b/internal/file/testdata/test.jpg differ diff --git a/internal/file/types.go b/internal/file/types.go new file mode 100644 index 000000000..943a19f87 --- /dev/null +++ b/internal/file/types.go @@ -0,0 +1,97 @@ +package file + +import ( + _ "image/gif" // Import for image. + _ "image/jpeg" + _ "image/png" +) + +type Type string + +const ( + // JPEG image file. + TypeJpeg Type = "jpg" + // PNG image file. + TypePng Type = "png" + // RAW image file. + TypeRaw Type = "raw" + // High Efficiency Image File Format. + TypeHEIF Type = "heif" // High Efficiency Image File Format + // Movie file. + TypeMovie Type = "mov" + // Adobe XMP sidecar file (XML). + TypeXMP Type = "xmp" + // Apple sidecar file (XML). + TypeAAE Type = "aae" + // XML metadata / config / sidecar file. + TypeXML Type = "xml" + // YAML metadata / config / sidecar file. + TypeYaml Type = "yml" + // Text config / sidecar file. + TypeText Type = "txt" + // Markdown text sidecar file. + TypeMarkdown Type = "md" + // Unknown file format. + TypeOther Type = "unknown" +) + +const ( + // MimeTypeJpeg is jpeg image type + MimeTypeJpeg = "image/jpeg" +) + +// Ext lists all the available and supported image file formats. +var Ext = map[string]Type{ + ".crw": TypeRaw, + ".cr2": TypeRaw, + ".nef": TypeRaw, + ".arw": TypeRaw, + ".dng": TypeRaw, + ".mov": TypeMovie, + ".avi": TypeMovie, + ".yml": TypeYaml, + ".jpg": TypeJpeg, + ".thm": TypeJpeg, + ".jpeg": TypeJpeg, + ".xmp": TypeXMP, + ".aae": TypeAAE, + ".heif": TypeHEIF, + ".heic": TypeHEIF, + ".3fr": TypeRaw, + ".ari": TypeRaw, + ".bay": TypeRaw, + ".cr3": TypeRaw, + ".cap": TypeRaw, + ".data": TypeRaw, + ".dcs": TypeRaw, + ".dcr": TypeRaw, + ".drf": TypeRaw, + ".eip": TypeRaw, + ".erf": TypeRaw, + ".fff": TypeRaw, + ".gpr": TypeRaw, + ".iiq": TypeRaw, + ".k25": TypeRaw, + ".kdc": TypeRaw, + ".mdc": TypeRaw, + ".mef": TypeRaw, + ".mos": TypeRaw, + ".mrw": TypeRaw, + ".nrw": TypeRaw, + ".obm": TypeRaw, + ".orf": TypeRaw, + ".pef": TypeRaw, + ".ptx": TypeRaw, + ".pxn": TypeRaw, + ".r3d": TypeRaw, + ".raf": TypeRaw, + ".raw": TypeRaw, + ".rwl": TypeRaw, + ".rw2": TypeRaw, + ".rwz": TypeRaw, + ".sr2": TypeRaw, + ".srf": TypeRaw, + ".srw": TypeRaw, + ".tif": TypeRaw, + ".x3f": TypeRaw, +} diff --git a/internal/util/zip.go b/internal/file/zip.go similarity index 60% rename from internal/util/zip.go rename to internal/file/zip.go index a998827c7..7957058c5 100644 --- a/internal/util/zip.go +++ b/internal/file/zip.go @@ -1,15 +1,16 @@ -package util +package file import ( "archive/zip" "io" "os" + "strings" ) // ZipFiles compresses one or many files into a single zip archive file. // Param 1: filename is the output zip file's name. // Param 2: files is a list of files to add to the zip. -func ZipFiles(filename string, files []string) error { +func Zip(filename string, files []string) error { newZipFile, err := os.Create(filename) if err != nil { return err @@ -21,7 +22,7 @@ func ZipFiles(filename string, files []string) error { // Add files to zip for _, file := range files { - if err = AddFileToZip(zipWriter, file); err != nil { + if err = AddToZip(zipWriter, file); err != nil { return err } } @@ -29,7 +30,7 @@ func ZipFiles(filename string, files []string) error { return nil } -func AddFileToZip(zipWriter *zip.Writer, filename string) error { +func AddToZip(zipWriter *zip.Writer, filename string) error { fileToZip, err := os.Open(filename) if err != nil { @@ -59,3 +60,29 @@ func AddFileToZip(zipWriter *zip.Writer, filename string) error { _, err = io.Copy(writer, fileToZip) return err } + +// Extract Zip file in destination directory +func Unzip(src, dest string) (fileNames []string, err error) { + r, err := zip.OpenReader(src) + if err != nil { + return fileNames, err + } + + defer r.Close() + + for _, f := range r.File { + // Skip directories like __OSX + if strings.HasPrefix(f.Name, "__") { + continue + } + + fn, err := copyToFile(f, dest) + if err != nil { + return fileNames, err + } + + fileNames = append(fileNames, fn) + } + + return fileNames, nil +} diff --git a/internal/nsfw/detector.go b/internal/nsfw/detector.go index 2ded275c3..5440312c9 100644 --- a/internal/nsfw/detector.go +++ b/internal/nsfw/detector.go @@ -9,7 +9,7 @@ import ( "path/filepath" "sync" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" tf "github.com/tensorflow/tensorflow/tensorflow/go" "github.com/tensorflow/tensorflow/tensorflow/go/op" ) @@ -30,7 +30,7 @@ func NewDetector(modelPath string) *Detector { // LabelsFromFile returns matching labels for a jpeg media file. func (t *Detector) LabelsFromFile(filename string) (result Labels, err error) { - if util.MimeType(filename) != "image/jpeg" { + if file.MimeType(filename) != "image/jpeg" { return result, fmt.Errorf("nsfw: \"%s\" is not a jpeg file", filename) } diff --git a/internal/photoprism/convert_test.go b/internal/photoprism/convert_test.go index 2a6070994..f429b3280 100644 --- a/internal/photoprism/convert_test.go +++ b/internal/photoprism/convert_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" "github.com/stretchr/testify/assert" ) @@ -30,7 +30,7 @@ func TestConvert_ToJpeg(t *testing.T) { jpegFilename := conf.ImportPath() + "/fern_green.jpg" - assert.Truef(t, util.Exists(jpegFilename), "file does not exist: %s", jpegFilename) + assert.Truef(t, file.Exists(jpegFilename), "file does not exist: %s", jpegFilename) t.Logf("Testing RAW to JPEG convert with %s", jpegFilename) @@ -66,7 +66,7 @@ func TestConvert_ToJpeg(t *testing.T) { imageRaw, _ := convert.ToJpeg(rawMediaFile) - assert.True(t, util.Exists(conf.ImportPath()+"/raw/IMG_2567.jpg"), "Jpeg file was not found - is Darktable installed?") + assert.True(t, file.Exists(conf.ImportPath()+"/raw/IMG_2567.jpg"), "Jpeg file was not found - is Darktable installed?") assert.NotEqual(t, rawFilename, imageRaw.filename) @@ -92,7 +92,7 @@ func TestConvert_Path(t *testing.T) { jpegFilename := conf.ImportPath() + "/raw/canon_eos_6d.jpg" - assert.True(t, util.Exists(jpegFilename), "Jpeg file was not found - is Darktable installed?") + assert.True(t, file.Exists(jpegFilename), "Jpeg file was not found - is Darktable installed?") image, err := NewMediaFile(jpegFilename) @@ -108,15 +108,15 @@ func TestConvert_Path(t *testing.T) { existingJpegFilename := conf.ImportPath() + "/raw/IMG_2567.jpg" - oldHash := util.Hash(existingJpegFilename) + oldHash := file.Hash(existingJpegFilename) os.Remove(existingJpegFilename) convert.Path(conf.ImportPath()) - newHash := util.Hash(existingJpegFilename) + newHash := file.Hash(existingJpegFilename) - assert.True(t, util.Exists(existingJpegFilename), "Jpeg file was not found - is Darktable installed?") + assert.True(t, file.Exists(existingJpegFilename), "Jpeg file was not found - is Darktable installed?") assert.NotEqual(t, oldHash, newHash, "Fingerprint of old and new JPEG file must not be the same") } diff --git a/internal/photoprism/import.go b/internal/photoprism/import.go index 52fdaf6e8..7ee467d12 100644 --- a/internal/photoprism/import.go +++ b/internal/photoprism/import.go @@ -12,8 +12,7 @@ import ( "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/event" - - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" ) // Import represents an importer that can copy/move MediaFiles to the originals directory. @@ -164,7 +163,7 @@ func (imp *Import) Start(importPath string) { if imp.removeEmptyDirectories { // Remove empty directories from import path for _, directory := range directories { - if util.DirectoryIsEmpty(directory) { + if file.IsEmpty(directory) { if err := os.Remove(directory); err != nil { log.Errorf("import: could not deleted empty directory \"%s\" (%s)", directory, err) } else { @@ -202,8 +201,8 @@ func (imp *Import) DestinationFilename(mainFile *MediaFile, mediaFile *MediaFile result := pathName + string(os.PathSeparator) + fileName + fileExtension - for util.Exists(result) { - if mediaFile.Hash() == util.Hash(result) { + for file.Exists(result) { + if mediaFile.Hash() == file.Hash(result) { return result, fmt.Errorf("file already exists: %s", result) } diff --git a/internal/photoprism/mediafile.go b/internal/photoprism/mediafile.go index 662a68c53..d5de4abf1 100644 --- a/internal/photoprism/mediafile.go +++ b/internal/photoprism/mediafile.go @@ -13,7 +13,7 @@ import ( "github.com/djherbis/times" "github.com/photoprism/photoprism/internal/entity" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" ) // MediaFile represents a single photo, video or sidecar file. @@ -22,7 +22,7 @@ type MediaFile struct { dateCreated time.Time timeZone string hash string - fileType FileType + fileType file.Type mimeType string perceptualHash string width int @@ -33,13 +33,13 @@ type MediaFile struct { // NewMediaFile returns a new MediaFile. func NewMediaFile(filename string) (*MediaFile, error) { - if !util.Exists(filename) { + if !file.Exists(filename) { return nil, fmt.Errorf("file does not exist: %s", filename) } instance := &MediaFile{ filename: filename, - fileType: FileTypeOther, + fileType: file.TypeOther, } return instance, nil @@ -231,7 +231,7 @@ func (m *MediaFile) CanonicalNameFromFileWithDirectory() string { // Hash return a sha1 hash of a MediaFile based on the filename. func (m *MediaFile) Hash() string { if len(m.hash) == 0 { - m.hash = util.Hash(m.Filename()) + m.hash = file.Hash(m.Filename()) } return m.hash @@ -242,7 +242,7 @@ func (m *MediaFile) EditedFilename() string { basename := filepath.Base(m.filename) if strings.ToUpper(basename[:4]) == "IMG_" && strings.ToUpper(basename[:5]) != "IMG_E" { - if filename := filepath.Dir(m.filename) + string(os.PathSeparator) + basename[:4] + "E" + basename[4:]; util.Exists(filename) { + if filename := filepath.Dir(m.filename) + string(os.PathSeparator) + basename[:4] + "E" + basename[4:]; file.Exists(filename) { return filename } } @@ -381,7 +381,7 @@ func (m *MediaFile) MimeType() string { return m.mimeType } - m.mimeType = util.MimeType(m.Filename()) + m.mimeType = file.MimeType(m.Filename()) return m.mimeType } @@ -397,7 +397,7 @@ func (m *MediaFile) openFile() (*os.File, error) { // Exists checks if a media file exists by filename. func (m *MediaFile) Exists() bool { - return util.Exists(m.Filename()) + return file.Exists(m.Filename()) } // Remove a media file. @@ -480,13 +480,13 @@ func (m *MediaFile) IsJpeg() bool { } // Type returns the type of the media file. -func (m *MediaFile) Type() FileType { - return FileExtensions[m.Extension()] +func (m *MediaFile) Type() file.Type { + return file.Ext[m.Extension()] } // HasType returns true if this media file is of a given type. -func (m *MediaFile) HasType(t FileType) bool { - if t == FileTypeJpeg { +func (m *MediaFile) HasType(t file.Type) bool { + if t == file.TypeJpeg { return m.IsJpeg() } @@ -495,28 +495,28 @@ func (m *MediaFile) HasType(t FileType) bool { // IsRaw returns true if this media file a RAW file. func (m *MediaFile) IsRaw() bool { - return m.HasType(FileTypeRaw) + return m.HasType(file.TypeRaw) } // IsHEIF returns true if this media file is a High Efficiency Image File Format file. func (m *MediaFile) IsHEIF() bool { - return m.HasType(FileTypeHEIF) + return m.HasType(file.TypeHEIF) } // IsSidecar returns true if this media file is a sidecar file (containing metadata). func (m *MediaFile) IsSidecar() bool { switch m.Type() { - case FileTypeXMP: + case file.TypeXMP: return true - case FileTypeAAE: + case file.TypeAAE: return true - case FileTypeXML: + case file.TypeXML: return true - case FileTypeYaml: + case file.TypeYaml: return true - case FileTypeText: + case file.TypeText: return true - case FileTypeMarkdown: + case file.TypeMarkdown: return true default: return false @@ -526,7 +526,7 @@ func (m *MediaFile) IsSidecar() bool { // IsVideo returns true if this media file is a video file. func (m *MediaFile) IsVideo() bool { switch m.Type() { - case FileTypeMovie: + case file.TypeMovie: return true } @@ -544,9 +544,9 @@ func (m *MediaFile) Jpeg() (*MediaFile, error) { return m, nil } - jpegFilename := fmt.Sprintf("%s.%s", m.DirectoryBasename(), FileTypeJpeg) + jpegFilename := fmt.Sprintf("%s.%s", m.DirectoryBasename(), file.TypeJpeg) - if !util.Exists(jpegFilename) { + if !file.Exists(jpegFilename) { return nil, fmt.Errorf("jpeg file does not exist: %s", jpegFilename) } diff --git a/internal/photoprism/mediafile_test.go b/internal/photoprism/mediafile_test.go index 2a92144dd..25879c5dd 100644 --- a/internal/photoprism/mediafile_test.go +++ b/internal/photoprism/mediafile_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" "github.com/photoprism/photoprism/internal/config" "github.com/stretchr/testify/assert" @@ -514,7 +514,7 @@ func TestMediaFile_Move(t *testing.T) { f, err := NewMediaFile(conf.ExamplesPath() + "/table_white.jpg") assert.Nil(t, err) f.Copy(origName) - assert.True(t, util.Exists(origName)) + assert.True(t, file.Exists(origName)) m, err := NewMediaFile(origName) assert.Nil(t, err) @@ -523,7 +523,7 @@ func TestMediaFile_Move(t *testing.T) { t.Errorf("failed to move: %s", err) } - assert.True(t, util.Exists(destName)) + assert.True(t, file.Exists(destName)) assert.Equal(t, destName, m.Filename()) } @@ -539,7 +539,7 @@ func TestMediaFile_Copy(t *testing.T) { mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/table_white.jpg") assert.Nil(t, err) mediaFile.Copy(tmpPath + "table_whitecopy.jpg") - assert.True(t, util.Exists(tmpPath+"table_whitecopy.jpg")) + assert.True(t, file.Exists(tmpPath+"table_whitecopy.jpg")) } func TestMediaFile_Extension(t *testing.T) { diff --git a/internal/photoprism/tensorflow.go b/internal/photoprism/tensorflow.go index 6b7cc7822..1c136befe 100644 --- a/internal/photoprism/tensorflow.go +++ b/internal/photoprism/tensorflow.go @@ -16,7 +16,7 @@ import ( "github.com/disintegration/imaging" "github.com/photoprism/photoprism/internal/config" - "github.com/photoprism/photoprism/internal/util" + "github.com/photoprism/photoprism/internal/file" tf "github.com/tensorflow/tensorflow/tensorflow/go" "gopkg.in/yaml.v2" ) @@ -69,7 +69,7 @@ func (t *TensorFlow) loadLabelRules() (err error) { log.Debugf("tensorflow: loading label rules from \"%s\"", filepath.Base(fileName)) - if !util.Exists(fileName) { + if !file.Exists(fileName) { e := fmt.Errorf("tensorflow: label rules file not found in \"%s\"", filepath.Base(fileName)) log.Error(e.Error()) return e diff --git a/internal/photoprism/thumbnails.go b/internal/photoprism/thumbnails.go index be5bbe042..88d9d5fc3 100644 --- a/internal/photoprism/thumbnails.go +++ b/internal/photoprism/thumbnails.go @@ -3,85 +3,19 @@ package photoprism import ( "fmt" "image" - "image/png" "os" - "path" "path/filepath" "strings" "time" - "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/file" + "github.com/photoprism/photoprism/internal/thumb" "github.com/disintegration/imaging" "github.com/photoprism/photoprism/internal/util" ) -var ( - MaxThumbWidth = 8192 - MaxThumbHeight = 8192 - JpegQuality = 95 - JpegQualitySmall = 80 -) - -const ( - ResampleFillCenter ResampleOption = iota - ResampleFillTopLeft - ResampleFillBottomRight - ResampleFit - ResampleResize - ResampleNearestNeighbor - ResampleLanczos - ResamplePng -) - -type ResampleOption int - -var ResampleMethods = map[ResampleOption]string{ - ResampleFillCenter: "center", - ResampleFillTopLeft: "left", - ResampleFillBottomRight: "right", - ResampleFit: "fit", - ResampleResize: "resize", -} - -type ThumbnailType struct { - Source string - Width int - Height int - Public bool - Options []ResampleOption -} - -var ThumbnailTypes = map[string]ThumbnailType{ - "tile_50": {"tile_500", 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleLanczos}}, - "tile_100": {"tile_500", 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleLanczos}}, - "tile_224": {"tile_500", 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleLanczos}}, - "tile_500": {"", 500, 500, false, []ResampleOption{ResampleFillCenter, ResampleLanczos}}, - "colors": {"fit_720", 3, 3, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}}, - "left_224": {"fit_720", 224, 224, false, []ResampleOption{ResampleFillTopLeft, ResampleLanczos}}, - "right_224": {"fit_720", 224, 224, false, []ResampleOption{ResampleFillBottomRight, ResampleLanczos}}, - "fit_720": {"", 720, 720, true, []ResampleOption{ResampleFit, ResampleLanczos}}, - "fit_1280": {"fit_2048", 1280, 1024, true, []ResampleOption{ResampleFit, ResampleLanczos}}, - "fit_1920": {"fit_2048", 1920, 1200, true, []ResampleOption{ResampleFit, ResampleLanczos}}, - "fit_2048": {"", 2048, 2048, true, []ResampleOption{ResampleFit, ResampleLanczos}}, - "fit_2560": {"", 2560, 1600, true, []ResampleOption{ResampleFit, ResampleLanczos}}, - "fit_3840": {"", 3840, 2400, true, []ResampleOption{ResampleFit, ResampleLanczos}}, -} - -var DefaultThumbnails = []string{ - "fit_3840", "fit_2560", "fit_2048", "fit_1920", "fit_1280", "fit_720", "right_224", "left_224", "colors", "tile_500", "tile_224", "tile_100", "tile_50", -} - -func init() { - for name, t := range ThumbnailTypes { - if t.Public { - thumb := config.Thumbnail{Name: name, Width: t.Width, Height: t.Height} - config.Thumbnails = append(config.Thumbnails, thumb) - } - } -} - // CreateThumbnailsFromOriginals creates default thumbnails for all originals. func CreateThumbnailsFromOriginals(originalsPath string, thumbnailsPath string, force bool) error { err := filepath.Walk(originalsPath, func(filename string, fileInfo os.FileInfo, err error) error { @@ -120,14 +54,14 @@ func CreateThumbnailsFromOriginals(originalsPath string, thumbnailsPath string, // Thumbnail returns a thumbnail filename. func (m *MediaFile) Thumbnail(path string, typeName string) (filename string, err error) { - thumbType, ok := ThumbnailTypes[typeName] + thumbType, ok := thumb.Types[typeName] if !ok { log.Errorf("invalid type: %s", typeName) return "", fmt.Errorf("invalid type: %s", typeName) } - thumbnail, err := ThumbnailFromFile(m.Filename(), m.Hash(), path, thumbType.Width, thumbType.Height, thumbType.Options...) + thumbnail, err := thumb.FromFile(m.Filename(), m.Hash(), path, thumbType.Width, thumbType.Height, thumbType.Options...) if err != nil { log.Errorf("could not create thumbnail: %s", err) @@ -148,157 +82,6 @@ func (m *MediaFile) Resample(path string, typeName string) (img image.Image, err return imaging.Open(filename, imaging.AutoOrientation(true)) } -func ResampleOptions(opts ...ResampleOption) (method ResampleOption, filter imaging.ResampleFilter, format FileType) { - method = ResampleFit - filter = imaging.Lanczos - format = FileTypeJpeg - - for _, option := range opts { - switch option { - case ResamplePng: - format = FileTypePng - case ResampleNearestNeighbor: - filter = imaging.NearestNeighbor - case ResampleLanczos: - filter = imaging.Lanczos - case ResampleFillTopLeft: - method = ResampleFillTopLeft - case ResampleFillCenter: - method = ResampleFillCenter - case ResampleFillBottomRight: - method = ResampleFillBottomRight - case ResampleFit: - method = ResampleFit - case ResampleResize: - method = ResampleResize - default: - panic(fmt.Errorf("not a valid resample option: %d", option)) - } - } - - return method, filter, format -} - -func Resample(img image.Image, width, height int, opts ...ResampleOption) (result image.Image) { - method, filter, _ := ResampleOptions(opts...) - - if method == ResampleFit { - result = imaging.Fit(img, width, height, filter) - } else if method == ResampleFillCenter { - result = imaging.Fill(img, width, height, imaging.Center, filter) - } else if method == ResampleFillTopLeft { - result = imaging.Fill(img, width, height, imaging.TopLeft, filter) - } else if method == ResampleFillBottomRight { - result = imaging.Fill(img, width, height, imaging.BottomRight, filter) - } else if method == ResampleResize { - result = imaging.Resize(img, width, height, filter) - } - - return result -} - -func ThumbnailPostfix(width, height int, opts ...ResampleOption) (result string) { - method, _, format := ResampleOptions(opts...) - - result = fmt.Sprintf("%dx%d_%s.%s", width, height, ResampleMethods[method], format) - - return result -} - -func ThumbnailFilename(hash string, thumbPath string, width, height int, opts ...ResampleOption) (filename string, err error) { - if width < 0 || width > MaxThumbWidth { - return "", fmt.Errorf("width has an invalid value: %d", width) - } - - if height < 0 || height > MaxThumbHeight { - return "", fmt.Errorf("height has an invalid value: %d", height) - } - - if len(hash) < 4 { - return "", fmt.Errorf("file hash is empty or too short: %s", hash) - } - - if len(thumbPath) == 0 { - return "", fmt.Errorf("thumbnail path is empty: %s", thumbPath) - } - - postfix := ThumbnailPostfix(width, height, opts...) - p := path.Join(thumbPath, hash[0:1], hash[1:2], hash[2:3]) - - if err := os.MkdirAll(p, os.ModePerm); err != nil { - return "", err - } - - filename = fmt.Sprintf("%s/%s_%s", p, hash, postfix) - - return filename, nil -} - -func ThumbnailFromFile(imageFilename string, hash string, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) { - if len(hash) < 4 { - return "", fmt.Errorf("file hash is empty or too short: %s", hash) - } - - if len(imageFilename) < 4 { - return "", fmt.Errorf("image filename is empty or too short: %s", imageFilename) - } - - fileName, err = ThumbnailFilename(hash, thumbPath, width, height, opts...) - - if err != nil { - log.Errorf("can't determine thumb filename: %s", err) - return "", err - } - - if util.Exists(fileName) { - return fileName, nil - } - - img, err := imaging.Open(imageFilename, imaging.AutoOrientation(true)) - - if err != nil { - log.Errorf("can't open original: %s", err) - return "", err - } - - if _, err := CreateThumbnail(img, fileName, width, height, opts...); err != nil { - return "", err - } - - return fileName, nil -} - -func CreateThumbnail(img image.Image, fileName string, width, height int, opts ...ResampleOption) (result image.Image, err error) { - if width < 0 || width > MaxThumbWidth { - return img, fmt.Errorf("width has an invalid value: %d", width) - } - - if height < 0 || height > MaxThumbHeight { - return img, fmt.Errorf("height has an invalid value: %d", height) - } - - result = Resample(img, width, height, opts...) - - var saveOption imaging.EncodeOption - - if filepath.Ext(fileName) == "."+string(FileTypePng) { - saveOption = imaging.PNGCompressionLevel(png.DefaultCompression) - } else if width <= 150 && height <= 150 { - saveOption = imaging.JPEGQuality(JpegQualitySmall) - } else { - saveOption = imaging.JPEGQuality(JpegQuality) - } - - err = imaging.Save(result, fileName, saveOption) - - if err != nil { - log.Errorf("failed to save thumbnail: %v", err) - return result, err - } - - return result, nil -} - func (m *MediaFile) CreateDefaultThumbnails(thumbPath string, force bool) (err error) { defer util.ProfileTime(time.Now(), fmt.Sprintf("creating thumbnails for \"%s\"", m.Filename())) @@ -314,31 +97,31 @@ func (m *MediaFile) CreateDefaultThumbnails(thumbPath string, force bool) (err e var sourceImg image.Image var sourceImgType string - for _, name := range DefaultThumbnails { - thumbType := ThumbnailTypes[name] + for _, name := range thumb.DefaultTypes { + thumbType := thumb.Types[name] - if thumbType.Height > MaxThumbHeight || thumbType.Width > MaxThumbWidth { + if thumbType.Height > thumb.MaxHeight || thumbType.Width > thumb.MaxWidth { log.Debugf("thumbs: size exceeds limit (width %d, height %d)", thumbType.Width, thumbType.Height) continue } - if fileName, err := ThumbnailFilename(hash, thumbPath, thumbType.Width, thumbType.Height, thumbType.Options...); err != nil { + if fileName, err := thumb.Filename(hash, thumbPath, thumbType.Width, thumbType.Height, thumbType.Options...); err != nil { log.Errorf("could not create %s thumbnail: \"%s\"", name, err) return err } else { - if !force && util.Exists(fileName) { + if !force && file.Exists(fileName) { continue } if thumbType.Source != "" { if thumbType.Source == sourceImgType && sourceImg != nil { - _, err = CreateThumbnail(sourceImg, fileName, thumbType.Width, thumbType.Height, thumbType.Options...) + _, err = thumb.Create(sourceImg, fileName, thumbType.Width, thumbType.Height, thumbType.Options...) } else { - _, err = CreateThumbnail(img, fileName, thumbType.Width, thumbType.Height, thumbType.Options...) + _, err = thumb.Create(img, fileName, thumbType.Width, thumbType.Height, thumbType.Options...) } } else { - sourceImg, err = CreateThumbnail(img, fileName, thumbType.Width, thumbType.Height, thumbType.Options...) + sourceImg, err = thumb.Create(img, fileName, thumbType.Width, thumbType.Height, thumbType.Options...) sourceImgType = name } diff --git a/internal/photoprism/thumbnails_test.go b/internal/photoprism/thumbnails_test.go index 93423c01d..efc14adcb 100644 --- a/internal/photoprism/thumbnails_test.go +++ b/internal/photoprism/thumbnails_test.go @@ -7,6 +7,7 @@ import ( "github.com/disintegration/imaging" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/nsfw" + "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/config" "github.com/stretchr/testify/assert" @@ -117,7 +118,7 @@ func TestThumbnails_Resample(t *testing.T) { } -func TestThumbnails_ThumbnailFilename(t *testing.T) { +func TestThumbnails_Filename(t *testing.T) { conf := config.TestConfig() thumbsPath := conf.CachePath() + "/_tmp" @@ -129,25 +130,25 @@ func TestThumbnails_ThumbnailFilename(t *testing.T) { } t.Run("", func(t *testing.T) { - filename, err := ThumbnailFilename("99988", thumbsPath, 150, 150, ResampleFit, ResampleNearestNeighbor) + filename, err := thumb.Filename("99988", thumbsPath, 150, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) assert.Nil(t, err) assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/testdata/cache/_tmp/9/9/9/99988_150x150_fit.jpg", filename) }) t.Run("hash too short", func(t *testing.T) { - _, err := ThumbnailFilename("999", thumbsPath, 150, 150, ResampleFit, ResampleNearestNeighbor) + _, err := thumb.Filename("999", thumbsPath, 150, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) assert.Equal(t, "file hash is empty or too short: 999", err.Error()) }) t.Run("invalid width", func(t *testing.T) { - _, err := ThumbnailFilename("99988", thumbsPath, -4, 150, ResampleFit, ResampleNearestNeighbor) + _, err := thumb.Filename("99988", thumbsPath, -4, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) assert.Equal(t, "width has an invalid value: -4", err.Error()) }) t.Run("invalid height", func(t *testing.T) { - _, err := ThumbnailFilename("99988", thumbsPath, 200, -1, ResampleFit, ResampleNearestNeighbor) + _, err := thumb.Filename("99988", thumbsPath, 200, -1, thumb.ResampleFit, thumb.ResampleNearestNeighbor) assert.Equal(t, "height has an invalid value: -1", err.Error()) }) t.Run("empty thumbpath", func(t *testing.T) { path := "" - _, err := ThumbnailFilename("99988", path, 200, 150, ResampleFit, ResampleNearestNeighbor) + _, err := thumb.Filename("99988", path, 200, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) assert.Equal(t, "thumbnail path is empty: ", err.Error()) }) } @@ -169,9 +170,9 @@ func TestThumbnails_ThumbnailFromFile(t *testing.T) { FileHash: "1234568889", } - thumb, err := ThumbnailFromFile(fileModel.FileName, fileModel.FileHash, thumbsPath, 224, 224) + thumbnail, err := thumb.FromFile(fileModel.FileName, fileModel.FileHash, thumbsPath, 224, 224) assert.Nil(t, err) - assert.FileExists(t, thumb) + assert.FileExists(t, thumbnail) }) t.Run("hash too short", func(t *testing.T) { @@ -180,7 +181,7 @@ func TestThumbnails_ThumbnailFromFile(t *testing.T) { FileHash: "123", } - _, err := ThumbnailFromFile(fileModel.FileName, fileModel.FileHash, thumbsPath, 224, 224) + _, err := thumb.FromFile(fileModel.FileName, fileModel.FileHash, thumbsPath, 224, 224) assert.Equal(t, "file hash is empty or too short: 123", err.Error()) }) t.Run("filename too short", func(t *testing.T) { @@ -189,7 +190,7 @@ func TestThumbnails_ThumbnailFromFile(t *testing.T) { FileHash: "12367890", } - _, err := ThumbnailFromFile(fileModel.FileName, fileModel.FileHash, thumbsPath, 224, 224) + _, err := thumb.FromFile(fileModel.FileName, fileModel.FileHash, thumbsPath, 224, 224) assert.Equal(t, "image filename is empty or too short: xxx", err.Error()) }) } @@ -210,7 +211,7 @@ func TestThumbnails_CreateThumbnail(t *testing.T) { } t.Run("valid parameter", func(t *testing.T) { - expectedFilename, err := ThumbnailFilename("12345", thumbsPath, 150, 150, ResampleFit, ResampleNearestNeighbor) + expectedFilename, err := thumb.Filename("12345", thumbsPath, 150, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) if err != nil { t.Error(err) @@ -222,13 +223,13 @@ func TestThumbnails_CreateThumbnail(t *testing.T) { t.Errorf("can't open original: %s", err) } - thumb, err := CreateThumbnail(img, expectedFilename, 150, 150, ResampleFit, ResampleNearestNeighbor) + thumbnail, err := thumb.Create(img, expectedFilename, 150, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) assert.Empty(t, err) - assert.NotNil(t, thumb) + assert.NotNil(t, thumbnail) - bounds := thumb.Bounds() + bounds := thumbnail.Bounds() assert.Equal(t, 150, bounds.Dx()) assert.Equal(t, 99, bounds.Dy()) @@ -236,7 +237,7 @@ func TestThumbnails_CreateThumbnail(t *testing.T) { assert.FileExists(t, expectedFilename) }) t.Run("invalid width", func(t *testing.T) { - expectedFilename, err := ThumbnailFilename("12345", thumbsPath, 150, 150, ResampleFit, ResampleNearestNeighbor) + expectedFilename, err := thumb.Filename("12345", thumbsPath, 150, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) if err != nil { t.Error(err) @@ -248,7 +249,7 @@ func TestThumbnails_CreateThumbnail(t *testing.T) { t.Errorf("can't open original: %s", err) } - thumbnail, err := CreateThumbnail(img, expectedFilename, -1, 150, ResampleFit, ResampleNearestNeighbor) + thumbnail, err := thumb.Create(img, expectedFilename, -1, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) assert.Equal(t, "width has an invalid value: -1", err.Error()) bounds := thumbnail.Bounds() @@ -256,7 +257,7 @@ func TestThumbnails_CreateThumbnail(t *testing.T) { }) t.Run("invalid height", func(t *testing.T) { - expectedFilename, err := ThumbnailFilename("12345", thumbsPath, 150, 150, ResampleFit, ResampleNearestNeighbor) + expectedFilename, err := thumb.Filename("12345", thumbsPath, 150, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor) if err != nil { t.Error(err) @@ -268,7 +269,7 @@ func TestThumbnails_CreateThumbnail(t *testing.T) { t.Errorf("can't open original: %s", err) } - thumbnail, err := CreateThumbnail(img, expectedFilename, 150, -1, ResampleFit, ResampleNearestNeighbor) + thumbnail, err := thumb.Create(img, expectedFilename, 150, -1, thumb.ResampleFit, thumb.ResampleNearestNeighbor) assert.Equal(t, "height has an invalid value: -1", err.Error()) bounds := thumbnail.Bounds() @@ -294,7 +295,7 @@ func TestThumbnails_CreateDefaultThumbnails(t *testing.T) { assert.Empty(t, err) - thumbFilename, err := ThumbnailFilename(m.Hash(), thumbsPath, ThumbnailTypes["tile_50"].Width, ThumbnailTypes["tile_50"].Height, ThumbnailTypes["tile_50"].Options...) + thumbFilename, err := thumb.Filename(m.Hash(), thumbsPath, thumb.Types["tile_50"].Width, thumb.Types["tile_50"].Height, thumb.Types["tile_50"].Options...) assert.Empty(t, err) diff --git a/internal/rnd/rnd.go b/internal/rnd/rnd.go new file mode 100644 index 000000000..236e9a128 --- /dev/null +++ b/internal/rnd/rnd.go @@ -0,0 +1,14 @@ +/* +Package config contains random token functions. + +Additional information can be found in our Developer Guide: + +https://github.com/photoprism/photoprism/wiki +*/ +package rnd + +import ( + "github.com/photoprism/photoprism/internal/event" +) + +var log = event.Log diff --git a/internal/util/token.go b/internal/rnd/token.go similarity index 83% rename from internal/util/token.go rename to internal/rnd/token.go index a5b88a3f4..eb53f7db6 100644 --- a/internal/util/token.go +++ b/internal/rnd/token.go @@ -1,4 +1,4 @@ -package util +package rnd import ( "crypto/rand" @@ -9,7 +9,7 @@ import ( uuid "github.com/satori/go.uuid" ) -func RandomToken(size uint) string { +func Token(size uint) string { if size > 10 || size < 1 { log.Fatalf("size out of range: %d", size) } @@ -32,14 +32,14 @@ func RandomToken(size uint) string { return string(result[:size]) } -func RandomPassword() string { - return RandomToken(8) +func Password() string { + return Token(8) } func ID() string { result := make([]byte, 0, 16) result = append(result, strconv.FormatInt(time.Now().UTC().Unix(), 36)[0:6]...) - result = append(result, RandomToken(10)...) + result = append(result, Token(10)...) return string(result) } diff --git a/internal/util/token_test.go b/internal/rnd/token_test.go similarity index 84% rename from internal/util/token_test.go rename to internal/rnd/token_test.go index 7e75e65b0..870561bde 100644 --- a/internal/util/token_test.go +++ b/internal/rnd/token_test.go @@ -1,4 +1,4 @@ -package util +package rnd import ( "testing" @@ -8,24 +8,24 @@ import ( func TestRandomToken(t *testing.T) { t.Run("size 4", func(t *testing.T) { - token := RandomToken(4) + token := Token(4) assert.NotEmpty(t, token) }) t.Run("size 8", func(t *testing.T) { - token := RandomToken(9) + token := Token(9) assert.NotEmpty(t, token) }) } func TestRandomPassword(t *testing.T) { - pw := RandomPassword() + pw := Password() t.Logf("password: %s", pw) assert.Equal(t, 8, len(pw)) } func BenchmarkRandomPassword(b *testing.B) { for n := 0; n < b.N; n++ { - RandomPassword() + Password() } } @@ -45,12 +45,12 @@ func BenchmarkUUID(b *testing.B) { func BenchmarkRandomToken4(b *testing.B) { for n := 0; n < b.N; n++ { - RandomToken(4) + Token(4) } } func BenchmarkRandomToken3(b *testing.B) { for n := 0; n < b.N; n++ { - RandomToken(3) + Token(3) } } diff --git a/internal/thumb/create.go b/internal/thumb/create.go new file mode 100644 index 000000000..5b0462ebf --- /dev/null +++ b/internal/thumb/create.go @@ -0,0 +1,165 @@ +package thumb + +import ( + "fmt" + "image" + "image/png" + "os" + "path" + "path/filepath" + + "github.com/photoprism/photoprism/internal/file" + + "github.com/disintegration/imaging" +) + +func ResampleOptions(opts ...ResampleOption) (method ResampleOption, filter imaging.ResampleFilter, format file.Type) { + method = ResampleFit + filter = imaging.Lanczos + format = file.TypeJpeg + + for _, option := range opts { + switch option { + case ResamplePng: + format = file.TypePng + case ResampleNearestNeighbor: + filter = imaging.NearestNeighbor + case ResampleLanczos: + filter = imaging.Lanczos + case ResampleFillTopLeft: + method = ResampleFillTopLeft + case ResampleFillCenter: + method = ResampleFillCenter + case ResampleFillBottomRight: + method = ResampleFillBottomRight + case ResampleFit: + method = ResampleFit + case ResampleResize: + method = ResampleResize + default: + panic(fmt.Errorf("not a valid resample option: %d", option)) + } + } + + return method, filter, format +} + +func Resample(img image.Image, width, height int, opts ...ResampleOption) (result image.Image) { + method, filter, _ := ResampleOptions(opts...) + + if method == ResampleFit { + result = imaging.Fit(img, width, height, filter) + } else if method == ResampleFillCenter { + result = imaging.Fill(img, width, height, imaging.Center, filter) + } else if method == ResampleFillTopLeft { + result = imaging.Fill(img, width, height, imaging.TopLeft, filter) + } else if method == ResampleFillBottomRight { + result = imaging.Fill(img, width, height, imaging.BottomRight, filter) + } else if method == ResampleResize { + result = imaging.Resize(img, width, height, filter) + } + + return result +} + +func Postfix(width, height int, opts ...ResampleOption) (result string) { + method, _, format := ResampleOptions(opts...) + + result = fmt.Sprintf("%dx%d_%s.%s", width, height, ResampleMethods[method], format) + + return result +} + +func Filename(hash string, thumbPath string, width, height int, opts ...ResampleOption) (filename string, err error) { + if width < 0 || width > MaxWidth { + return "", fmt.Errorf("width has an invalid value: %d", width) + } + + if height < 0 || height > MaxHeight { + return "", fmt.Errorf("height has an invalid value: %d", height) + } + + if len(hash) < 4 { + return "", fmt.Errorf("file hash is empty or too short: %s", hash) + } + + if len(thumbPath) == 0 { + return "", fmt.Errorf("thumbnail path is empty: %s", thumbPath) + } + + postfix := Postfix(width, height, opts...) + p := path.Join(thumbPath, hash[0:1], hash[1:2], hash[2:3]) + + if err := os.MkdirAll(p, os.ModePerm); err != nil { + return "", err + } + + filename = fmt.Sprintf("%s/%s_%s", p, hash, postfix) + + return filename, nil +} + +func FromFile(imageFilename string, hash string, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) { + if len(hash) < 4 { + return "", fmt.Errorf("file hash is empty or too short: %s", hash) + } + + if len(imageFilename) < 4 { + return "", fmt.Errorf("image filename is empty or too short: %s", imageFilename) + } + + fileName, err = Filename(hash, thumbPath, width, height, opts...) + + if err != nil { + log.Errorf("can't determine thumb filename: %s", err) + return "", err + } + + if file.Exists(fileName) { + return fileName, nil + } + + img, err := imaging.Open(imageFilename, imaging.AutoOrientation(true)) + + if err != nil { + log.Errorf("can't open original: %s", err) + return "", err + } + + if _, err := Create(img, fileName, width, height, opts...); err != nil { + return "", err + } + + return fileName, nil +} + +func Create(img image.Image, fileName string, width, height int, opts ...ResampleOption) (result image.Image, err error) { + if width < 0 || width > MaxWidth { + return img, fmt.Errorf("width has an invalid value: %d", width) + } + + if height < 0 || height > MaxHeight { + return img, fmt.Errorf("height has an invalid value: %d", height) + } + + result = Resample(img, width, height, opts...) + + var saveOption imaging.EncodeOption + + if filepath.Ext(fileName) == "."+string(file.TypePng) { + saveOption = imaging.PNGCompressionLevel(png.DefaultCompression) + } else if width <= 150 && height <= 150 { + saveOption = imaging.JPEGQuality(JpegQualitySmall) + } else { + saveOption = imaging.JPEGQuality(JpegQuality) + } + + err = imaging.Save(result, fileName, saveOption) + + if err != nil { + log.Errorf("failed to save thumbnail: %v", err) + return result, err + } + + return result, nil +} diff --git a/internal/thumb/thumb.go b/internal/thumb/thumb.go new file mode 100644 index 000000000..4fa66bd74 --- /dev/null +++ b/internal/thumb/thumb.go @@ -0,0 +1,14 @@ +/* +This package encapsulates JPEG thumbnail generation. + +Additional information can be found in our Developer Guide: + +https://github.com/photoprism/photoprism/wiki +*/ +package thumb + +import ( + "github.com/photoprism/photoprism/internal/event" +) + +var log = event.Log diff --git a/internal/thumb/types.go b/internal/thumb/types.go new file mode 100644 index 000000000..015765a5f --- /dev/null +++ b/internal/thumb/types.go @@ -0,0 +1,57 @@ +package thumb + +var ( + MaxWidth = 8192 + MaxHeight = 8192 + JpegQuality = 95 + JpegQualitySmall = 80 +) + +const ( + ResampleFillCenter ResampleOption = iota + ResampleFillTopLeft + ResampleFillBottomRight + ResampleFit + ResampleResize + ResampleNearestNeighbor + ResampleLanczos + ResamplePng +) + +type ResampleOption int + +var ResampleMethods = map[ResampleOption]string{ + ResampleFillCenter: "center", + ResampleFillTopLeft: "left", + ResampleFillBottomRight: "right", + ResampleFit: "fit", + ResampleResize: "resize", +} + +type Type struct { + Source string + Width int + Height int + Public bool + Options []ResampleOption +} + +var Types = map[string]Type{ + "tile_50": {"tile_500", 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleLanczos}}, + "tile_100": {"tile_500", 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleLanczos}}, + "tile_224": {"tile_500", 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleLanczos}}, + "tile_500": {"", 500, 500, false, []ResampleOption{ResampleFillCenter, ResampleLanczos}}, + "colors": {"fit_720", 3, 3, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}}, + "left_224": {"fit_720", 224, 224, false, []ResampleOption{ResampleFillTopLeft, ResampleLanczos}}, + "right_224": {"fit_720", 224, 224, false, []ResampleOption{ResampleFillBottomRight, ResampleLanczos}}, + "fit_720": {"", 720, 720, true, []ResampleOption{ResampleFit, ResampleLanczos}}, + "fit_1280": {"fit_2048", 1280, 1024, true, []ResampleOption{ResampleFit, ResampleLanczos}}, + "fit_1920": {"fit_2048", 1920, 1200, true, []ResampleOption{ResampleFit, ResampleLanczos}}, + "fit_2048": {"", 2048, 2048, true, []ResampleOption{ResampleFit, ResampleLanczos}}, + "fit_2560": {"", 2560, 1600, true, []ResampleOption{ResampleFit, ResampleLanczos}}, + "fit_3840": {"", 3840, 2400, true, []ResampleOption{ResampleFit, ResampleLanczos}}, +} + +var DefaultTypes = []string{ + "fit_3840", "fit_2560", "fit_2048", "fit_1920", "fit_1280", "fit_720", "right_224", "left_224", "colors", "tile_500", "tile_224", "tile_100", "tile_50", +}