mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
bbc4f2f276
commit
5ec90a5fff
55 changed files with 1322 additions and 590 deletions
20
Makefile
20
Makefile
|
|
@ -236,35 +236,35 @@ reset-sqlite:
|
|||
$(info Removing test database files...)
|
||||
find ./internal -type f -name ".test.*" -delete
|
||||
run-test-short:
|
||||
$(info Running short Go unit tests in parallel mode...)
|
||||
$(info Running short Go tests in parallel mode...)
|
||||
$(GOTEST) -parallel 2 -count 1 -cpu 2 -short -timeout 5m ./pkg/... ./internal/...
|
||||
run-test-go:
|
||||
$(info Running all Go unit tests...)
|
||||
$(info Running all Go tests...)
|
||||
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags slow -timeout 20m ./pkg/... ./internal/...
|
||||
run-test-mariadb:
|
||||
$(info Running all Go unit tests on MariaDB...)
|
||||
$(info Running all Go tests on MariaDB...)
|
||||
PHOTOPRISM_TEST_DRIVER="mysql" PHOTOPRISM_TEST_DSN="root:photoprism@tcp(mariadb:4001)/acceptance?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true" $(GOTEST) -parallel 1 -count 1 -cpu 1 -tags slow -timeout 20m ./pkg/... ./internal/...
|
||||
run-test-pkg:
|
||||
$(info Running all Go unit tests in "/pkg"...)
|
||||
$(info Running all Go tests in "/pkg"...)
|
||||
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./pkg/...
|
||||
run-test-api:
|
||||
$(info Running all API unit tests...)
|
||||
$(info Running all API tests...)
|
||||
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./internal/api/...
|
||||
test-parallel:
|
||||
$(info Running all Go unit tests in parallel mode...)
|
||||
$(info Running all Go tests in parallel mode...)
|
||||
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags slow -timeout 20m ./pkg/... ./internal/...
|
||||
test-verbose:
|
||||
$(info Running all Go unit tests in verbose mode...)
|
||||
$(info Running all Go tests in verbose mode...)
|
||||
$(GOTEST) -parallel 1 -count 1 -cpu 1 -tags slow -timeout 20m -v ./pkg/... ./internal/...
|
||||
test-race:
|
||||
$(info Running all Go unit tests with race detection in verbose mode...)
|
||||
$(info Running all Go tests with race detection in verbose mode...)
|
||||
$(GOTEST) -tags slow -race -timeout 60m -v ./pkg/... ./internal/...
|
||||
test-codecov:
|
||||
$(info Running all Go unit tests with code coverage report for codecov...)
|
||||
$(info Running all Go tests with code coverage report for codecov...)
|
||||
go test -parallel 1 -count 1 -cpu 1 -failfast -tags slow -timeout 30m -coverprofile coverage.txt -covermode atomic ./pkg/... ./internal/...
|
||||
scripts/codecov.sh -t $(CODECOV_TOKEN)
|
||||
test-coverage:
|
||||
$(info Running all Go unit tests with code coverage report...)
|
||||
$(info Running all Go tests with code coverage report...)
|
||||
go test -parallel 1 -count 1 -cpu 1 -failfast -tags slow -timeout 30m -coverprofile coverage.txt -covermode atomic ./pkg/... ./internal/...
|
||||
go tool cover -html=coverage.txt -o coverage.html
|
||||
docker-develop: docker-develop-latest
|
||||
|
|
|
|||
|
|
@ -73,20 +73,20 @@ func GetThumb(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
thumbName := thumb.Name(clean.Token(c.Param("size")))
|
||||
sizeName := thumb.Name(clean.Token(c.Param("size")))
|
||||
|
||||
size, ok := thumb.Sizes[thumbName]
|
||||
size, ok := thumb.Sizes[sizeName]
|
||||
|
||||
if !ok {
|
||||
log.Errorf("%s: invalid size %s", logPrefix, clean.Log(thumbName.String()))
|
||||
log.Errorf("%s: invalid size %s", logPrefix, clean.Log(sizeName.String()))
|
||||
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
|
||||
return
|
||||
}
|
||||
|
||||
if size.Uncached() && !conf.ThumbUncached() {
|
||||
thumbName, size = thumb.Find(conf.ThumbSizePrecached())
|
||||
sizeName, size = thumb.Find(conf.ThumbSizePrecached())
|
||||
|
||||
if thumbName == "" {
|
||||
if sizeName == "" {
|
||||
log.Errorf("%s: invalid size %d", logPrefix, conf.ThumbSizePrecached())
|
||||
c.Data(http.StatusOK, "image/svg+xml", photoIconSvg)
|
||||
return
|
||||
|
|
@ -94,7 +94,7 @@ func GetThumb(router *gin.RouterGroup) {
|
|||
}
|
||||
|
||||
cache := service.ThumbCache()
|
||||
cacheKey := CacheKey("thumbs", fileHash, string(thumbName))
|
||||
cacheKey := CacheKey("thumbs", fileHash, string(sizeName))
|
||||
|
||||
if cacheData, ok := cache.Get(cacheKey); ok {
|
||||
log.Tracef("api: cache hit for %s [%s]", cacheKey, time.Since(start))
|
||||
|
|
@ -119,7 +119,7 @@ func GetThumb(router *gin.RouterGroup) {
|
|||
|
||||
// Return existing thumbs straight away.
|
||||
if !download {
|
||||
if fileName, err := thumb.FileName(fileHash, conf.ThumbCachePath(), size.Width, size.Height, size.Options...); err == nil && fs.FileExists(fileName) {
|
||||
if fileName, err := size.ResolvedName(fileHash, conf.ThumbCachePath()); err == nil {
|
||||
AddThumbCacheHeader(c)
|
||||
c.File(fileName)
|
||||
return
|
||||
|
|
@ -152,7 +152,7 @@ func GetThumb(router *gin.RouterGroup) {
|
|||
|
||||
fileName := photoprism.FileName(f.FileRoot, f.FileName)
|
||||
|
||||
if !fs.FileExists(fileName) {
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
log.Errorf("%s: file %s is missing", logPrefix, clean.Log(f.FileName))
|
||||
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
|
||||
|
||||
|
|
@ -170,6 +170,12 @@ func GetThumb(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
// Choose the smallest fitting size if the original image is smaller.
|
||||
if size.Fit && f.Bounds().In(size.Bounds()) {
|
||||
size = thumb.FitBounds(f.Bounds())
|
||||
log.Tracef("%s: smallest fitting size for %s is %s (width %d, height %d)", logPrefix, clean.Log(f.FileName), size.Name, size.Width, size.Height)
|
||||
}
|
||||
|
||||
// Use original file if thumb size exceeds limit, see https://github.com/photoprism/photoprism/issues/157
|
||||
if size.ExceedsLimit() && c.Query("download") == "" {
|
||||
log.Debugf("%s: using original, size exceeds limit (width %d, height %d)", logPrefix, size.Width, size.Height)
|
||||
|
|
@ -180,32 +186,37 @@ func GetThumb(router *gin.RouterGroup) {
|
|||
return
|
||||
}
|
||||
|
||||
var thumbnail string
|
||||
// thumbName is the thumbnail filename.
|
||||
var thumbName string
|
||||
|
||||
// Try to find or create thumbnail image.
|
||||
if conf.ThumbUncached() || size.Uncached() {
|
||||
thumbnail, err = thumb.FromFile(fileName, f.FileHash, conf.ThumbCachePath(), size.Width, size.Height, f.FileOrientation, size.Options...)
|
||||
thumbName, err = size.FromFile(fileName, f.FileHash, conf.ThumbCachePath(), f.FileOrientation)
|
||||
} else {
|
||||
thumbnail, err = thumb.FromCache(fileName, f.FileHash, conf.ThumbCachePath(), size.Width, size.Height, size.Options...)
|
||||
thumbName, err = size.FromCache(fileName, f.FileHash, conf.ThumbCachePath())
|
||||
}
|
||||
|
||||
// Failed?
|
||||
if err != nil {
|
||||
log.Errorf("%s: %s", logPrefix, err)
|
||||
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
|
||||
return
|
||||
} else if thumbnail == "" {
|
||||
} else if thumbName == "" {
|
||||
log.Errorf("%s: %s has empty thumb name - possible bug", logPrefix, filepath.Base(fileName))
|
||||
c.Data(http.StatusOK, "image/svg+xml", brokenIconSvg)
|
||||
return
|
||||
}
|
||||
|
||||
cache.SetDefault(cacheKey, ThumbCache{thumbnail, f.ShareBase(0)})
|
||||
// Cache thumbnail filename to reduce the number of index queries.
|
||||
cache.SetDefault(cacheKey, ThumbCache{thumbName, f.ShareBase(0)})
|
||||
log.Debugf("cached %s [%s]", cacheKey, time.Since(start))
|
||||
|
||||
// Set the download or cache header and return the thumbnail.
|
||||
if download {
|
||||
c.FileAttachment(thumbnail, f.DownloadName(DownloadName(c), 0))
|
||||
c.FileAttachment(thumbName, f.DownloadName(DownloadName(c), 0))
|
||||
} else {
|
||||
AddThumbCacheHeader(c)
|
||||
c.File(thumbnail)
|
||||
c.File(thumbName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,8 +61,8 @@ func init() {
|
|||
}
|
||||
|
||||
// Init public thumb sizes for use in client apps.
|
||||
for i := len(thumb.DefaultSizes) - 1; i >= 0; i-- {
|
||||
name := thumb.DefaultSizes[i]
|
||||
for i := len(thumb.Names) - 1; i >= 0; i-- {
|
||||
name := thumb.Names[i]
|
||||
t := thumb.Sizes[name]
|
||||
|
||||
if t.Public {
|
||||
|
|
@ -458,7 +458,7 @@ func (c *Config) Debug() bool {
|
|||
|
||||
// Trace checks if trace mode is enabled, shows all log messages.
|
||||
func (c *Config) Trace() bool {
|
||||
return c.options.Trace
|
||||
return c.options.Trace || c.options.LogLevel == logrus.TraceLevel.String()
|
||||
}
|
||||
|
||||
// Test checks if test mode is enabled.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,11 @@ func ImageFromThumb(thumbName string, area Area, size Size, cache bool) (img ima
|
|||
// Extract hash from file name.
|
||||
hash := thumbHash(thumbName)
|
||||
|
||||
// Resolve symlinks.
|
||||
if thumbName, err = fs.Resolve(thumbName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compose cached crop image file name.
|
||||
cropBase := fmt.Sprintf("%s_%dx%d_crop_%s%s", hash, size.Width, size.Height, area.String(), fs.ExtJPEG)
|
||||
cropName := filepath.Join(filePath, cropBase)
|
||||
|
|
@ -113,7 +118,8 @@ func ThumbFileName(hash string, area Area, size Size, thumbPath string) (string,
|
|||
return "", fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
return fileName, nil
|
||||
// Resolve symlinks.
|
||||
return fs.Resolve(fileName)
|
||||
}
|
||||
|
||||
// FileWidth returns the minimal thumbnail width based on crop area and size.
|
||||
|
|
@ -142,9 +148,10 @@ func findIdealThumbFileName(hash string, width int, filePath string) (fileName s
|
|||
}
|
||||
|
||||
for i, s := range thumbFileSizes {
|
||||
name := filepath.Join(filePath, fmt.Sprintf(thumbFileNames[i], hash))
|
||||
// Resolve symlinks.
|
||||
name, err := fs.Resolve(filepath.Join(filePath, fmt.Sprintf(thumbFileNames[i], hash)))
|
||||
|
||||
if !fs.FileExists(name) {
|
||||
if err != nil || !fs.FileExists(name) {
|
||||
continue
|
||||
} else if s.Width < width {
|
||||
fileName = name
|
||||
|
|
@ -158,7 +165,12 @@ func findIdealThumbFileName(hash string, width int, filePath string) (fileName s
|
|||
}
|
||||
|
||||
// openIdealThumbFile opens the thumbnail file and returns an image.
|
||||
func openIdealThumbFile(fileName, hash string, area Area, size Size) (image.Image, error) {
|
||||
func openIdealThumbFile(fileName, hash string, area Area, size Size) (result image.Image, err error) {
|
||||
// Resolve symlinks.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(hash) != 40 || area.W <= 0 || size.Width <= 0 {
|
||||
// Not a standard thumb name with sha1 hash prefix.
|
||||
if imageBuffer, err := os.ReadFile(fileName); err != nil {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package crop
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -59,7 +60,7 @@ func TestThumbFileName(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
|
||||
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -98,18 +99,18 @@ func TestFindIdealThumbFileName(t *testing.T) {
|
|||
})
|
||||
t.Run("width: 500", func(t *testing.T) {
|
||||
r := findIdealThumbFileName("bccfeaa526a36e19b555fd4ca5e8f767d5604289", 500, "./testdata/b/c/c")
|
||||
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
|
||||
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
|
||||
})
|
||||
t.Run("width: 720", func(t *testing.T) {
|
||||
r := findIdealThumbFileName("bccfeaa526a36e19b555fd4ca5e8f767d5604289", 720, "./testdata/b/c/c")
|
||||
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
|
||||
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
|
||||
})
|
||||
t.Run("width: 800", func(t *testing.T) {
|
||||
r := findIdealThumbFileName("bccfeaa526a36e19b555fd4ca5e8f767d5604289", 800, "./testdata/b/c/c")
|
||||
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
|
||||
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
|
||||
})
|
||||
t.Run("width: 60", func(t *testing.T) {
|
||||
r := findIdealThumbFileName("bccfeaa526a36e19b555fd4ca5e8f767d5604289", 60, "./testdata/b/c/c")
|
||||
assert.Equal(t, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg", r)
|
||||
assert.True(t, strings.HasSuffix(r, "testdata/b/c/c/bccfeaa526a36e19b555fd4ca5e8f767d5604289_720x720_fit.jpg"), r)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package entity
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
|
@ -572,6 +573,11 @@ func (m *File) Panorama() bool {
|
|||
return float64(m.FileWidth)/float64(m.FileHeight) > 1.9
|
||||
}
|
||||
|
||||
// Bounds returns the file dimensions as image.Rectangle.
|
||||
func (m *File) Bounds() image.Rectangle {
|
||||
return image.Rectangle{Min: image.Point{}, Max: image.Point{X: m.FileWidth, Y: m.FileHeight}}
|
||||
}
|
||||
|
||||
// Projection returns the panorama projection name if any.
|
||||
func (m *File) Projection() projection.Type {
|
||||
return projection.New(m.FileProjection)
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ type Data struct {
|
|||
exif map[string]string
|
||||
}
|
||||
|
||||
// NewData creates a new metadata struct.
|
||||
func NewData() Data {
|
||||
// New returns a new metadata struct.
|
||||
func New() Data {
|
||||
return Data{}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ func (data *Data) Exif(fileName string, fileFormat fs.Type, bruteForce bool) (er
|
|||
}
|
||||
}()
|
||||
|
||||
// Resolve file name e.g. in case it's a symlink.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract raw Exif block.
|
||||
rawExif, err := RawExif(fileName, fileFormat, bruteForce)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ func (data *Data) JSON(jsonName, originalName string) (err error) {
|
|||
|
||||
quotedName := clean.Log(filepath.Base(jsonName))
|
||||
|
||||
if !fs.FileExists(jsonName) {
|
||||
// Resolve JSON file name e.g. in case it's a symlink.
|
||||
if jsonName, err = fs.Resolve(jsonName); err != nil {
|
||||
return fmt.Errorf("metadata: %s not found (%s)", quotedName, err)
|
||||
} else if !fs.FileExists(jsonName) {
|
||||
return fmt.Errorf("metadata: %s not found", quotedName)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
func TestData_AddKeywords(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
data := NewData()
|
||||
data := New()
|
||||
|
||||
assert.Equal(t, "", data.Keywords.String())
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ func TestData_AddKeywords(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("ignore", func(t *testing.T) {
|
||||
data := NewData()
|
||||
data := New()
|
||||
|
||||
assert.Equal(t, "", data.Keywords.String())
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ func TestData_AddKeywords(t *testing.T) {
|
|||
|
||||
func TestData_AutoAddKeywords(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
data := NewData()
|
||||
data := New()
|
||||
|
||||
assert.Equal(t, "", data.Keywords.String())
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ func TestData_AutoAddKeywords(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("ignore", func(t *testing.T) {
|
||||
data := NewData()
|
||||
data := New()
|
||||
|
||||
assert.Equal(t, "", data.Keywords.String())
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ func TestData_AutoAddKeywords(t *testing.T) {
|
|||
})
|
||||
|
||||
t.Run("ignore because too short", func(t *testing.T) {
|
||||
data := NewData()
|
||||
data := New()
|
||||
|
||||
assert.Equal(t, "", data.Keywords.String())
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"path/filepath"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
)
|
||||
|
||||
|
|
@ -23,9 +25,14 @@ func (data *Data) XMP(fileName string) (err error) {
|
|||
}
|
||||
}()
|
||||
|
||||
// Resolve file name e.g. in case it's a symlink.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return fmt.Errorf("metadata: %s %s (xmp)", err, clean.Log(filepath.Base(fileName)))
|
||||
}
|
||||
|
||||
doc := XmpDocument{}
|
||||
|
||||
if err := doc.Load(fileName); err != nil {
|
||||
if err = doc.Load(fileName); err != nil {
|
||||
return fmt.Errorf("metadata: cannot read %s (xmp)", clean.Log(filepath.Base(fileName)))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ func TestXMP(t *testing.T) {
|
|||
}
|
||||
|
||||
assert.Equal(t, "Night Shift / Berlin / 2020", data.Title)
|
||||
t.Log(data.TakenAt)
|
||||
assert.Equal(t, time.Date(2020, 1, 1, 17, 28, 25, 729626112, time.UTC), data.TakenAt)
|
||||
assert.Equal(t, "Michael Mayer", data.Artist)
|
||||
assert.Equal(t, "Example file for development", data.Description)
|
||||
|
|
|
|||
|
|
@ -152,20 +152,21 @@ func (ind *Index) Start(o IndexOptions) fs.Done {
|
|||
return errors.New("canceled")
|
||||
}
|
||||
|
||||
isDir := info.IsDir()
|
||||
isDir, _ := info.IsDirOrSymlinkToDir()
|
||||
isSymlink := info.IsSymlink()
|
||||
relName := fs.RelName(fileName, originalsPath)
|
||||
|
||||
// Skip directories and known files.
|
||||
if skip, result := fs.SkipWalk(fileName, isDir, isSymlink, done, ignore); skip {
|
||||
if (isSymlink || isDir) && result != filepath.SkipDir {
|
||||
folder := entity.NewFolder(entity.RootOriginals, relName, fs.BirthTime(fileName))
|
||||
|
||||
if err := folder.Create(); err == nil {
|
||||
log.Infof("index: added folder /%s", folder.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if isDir {
|
||||
if result != filepath.SkipDir {
|
||||
folder := entity.NewFolder(entity.RootOriginals, relName, fs.BirthTime(fileName))
|
||||
|
||||
if err := folder.Create(); err == nil {
|
||||
log.Infof("index: added folder /%s", folder.Path)
|
||||
}
|
||||
}
|
||||
|
||||
event.Publish("index.folder", event.Data{
|
||||
"filePath": relName,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID
|
|||
file, primaryFile := entity.File{}, entity.File{}
|
||||
|
||||
photo := entity.NewPhoto(o.Stack)
|
||||
metaData := meta.NewData()
|
||||
metaData := meta.New()
|
||||
labels := classify.Labels{}
|
||||
stripSequence := Config().Settings().StackSequences() && o.Stack
|
||||
|
||||
|
|
|
|||
|
|
@ -24,16 +24,11 @@ import (
|
|||
_ "golang.org/x/image/tiff"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/djherbis/times"
|
||||
"github.com/dustin/go-humanize/english"
|
||||
"github.com/mandykoh/prism/meta/autometa"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/meta"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/capture"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
|
|
@ -65,12 +60,13 @@ type MediaFile struct {
|
|||
}
|
||||
|
||||
// NewMediaFile returns a new media file.
|
||||
func NewMediaFile(fileName string) (*MediaFile, error) {
|
||||
m := &MediaFile{
|
||||
func NewMediaFile(fileName string) (m *MediaFile, err error) {
|
||||
// Create struct.
|
||||
m = &MediaFile{
|
||||
fileName: fileName,
|
||||
fileRoot: entity.RootUnknown,
|
||||
fileType: fs.UnknownType,
|
||||
metaData: meta.NewData(),
|
||||
metaData: meta.New(),
|
||||
width: -1,
|
||||
height: -1,
|
||||
}
|
||||
|
|
@ -91,7 +87,14 @@ func (m *MediaFile) Stat() (size int64, mod time.Time, err error) {
|
|||
return m.fileSize, m.modTime, m.statErr
|
||||
}
|
||||
|
||||
if s, err := os.Stat(m.FileName()); err != nil {
|
||||
fileName := m.FileName()
|
||||
|
||||
// Resolve symlinks.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
m.statErr = err
|
||||
m.modTime = time.Time{}
|
||||
m.fileSize = -1
|
||||
} else if s, err := os.Stat(fileName); err != nil {
|
||||
m.statErr = err
|
||||
m.modTime = time.Time{}
|
||||
m.fileSize = -1
|
||||
|
|
@ -561,14 +564,29 @@ func (m *MediaFile) MimeType() string {
|
|||
return m.mimeType
|
||||
}
|
||||
|
||||
m.mimeType = fs.MimeType(m.FileName())
|
||||
var err error
|
||||
fileName := m.FileName()
|
||||
|
||||
// Resolve symlinks.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return m.mimeType
|
||||
}
|
||||
|
||||
m.mimeType = fs.MimeType(fileName)
|
||||
|
||||
return m.mimeType
|
||||
}
|
||||
|
||||
// openFile opens the file and returns the descriptor.
|
||||
func (m *MediaFile) openFile() (*os.File, error) {
|
||||
handle, err := os.Open(m.fileName)
|
||||
func (m *MediaFile) openFile() (handle *os.File, err error) {
|
||||
fileName := m.FileName()
|
||||
|
||||
// Resolve symlinks.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return nil, fmt.Errorf("%s %s", err, clean.Log(m.RootRelName()))
|
||||
}
|
||||
|
||||
handle, err = os.Open(fileName)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
|
|
@ -934,7 +952,14 @@ func (m *MediaFile) DecodeConfig() (_ *image.Config, err error) {
|
|||
m.fileMutex.Lock()
|
||||
defer m.fileMutex.Unlock()
|
||||
|
||||
file, err := os.Open(m.FileName())
|
||||
fileName := m.FileName()
|
||||
|
||||
// Resolve symlinks.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return nil, fmt.Errorf("%s %s", err, clean.Log(m.RootRelName()))
|
||||
}
|
||||
|
||||
file, err := os.Open(fileName)
|
||||
|
||||
if err != nil || file == nil {
|
||||
return nil, err
|
||||
|
|
@ -1064,114 +1089,6 @@ func (m *MediaFile) Orientation() int {
|
|||
return 1
|
||||
}
|
||||
|
||||
// Thumbnail returns a thumbnail filename.
|
||||
func (m *MediaFile) Thumbnail(path string, sizeName thumb.Name) (filename string, err error) {
|
||||
size, ok := thumb.Sizes[sizeName]
|
||||
|
||||
if !ok {
|
||||
log.Errorf("media: invalid type %s", sizeName)
|
||||
return "", fmt.Errorf("media: invalid type %s", sizeName)
|
||||
}
|
||||
|
||||
thumbnail, err := thumb.FromFile(m.FileName(), m.Hash(), path, size.Width, size.Height, m.Orientation(), size.Options...)
|
||||
|
||||
if err != nil {
|
||||
err = fmt.Errorf("media: failed creating thumbnail for %s (%s)", clean.Log(m.BaseName()), err)
|
||||
log.Debug(err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return thumbnail, nil
|
||||
}
|
||||
|
||||
// Resample returns a resampled image of the file.
|
||||
func (m *MediaFile) Resample(path string, sizeName thumb.Name) (img image.Image, err error) {
|
||||
filename, err := m.Thumbnail(path, sizeName)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return imaging.Open(filename)
|
||||
}
|
||||
|
||||
// CreateThumbnails creates the default thumbnail sizes if the media file
|
||||
// is a JPEG and they don't exist yet (except force is true).
|
||||
func (m *MediaFile) CreateThumbnails(thumbPath string, force bool) (err error) {
|
||||
if !m.IsJpeg() {
|
||||
// Skip.
|
||||
return
|
||||
}
|
||||
|
||||
count := 0
|
||||
start := time.Now()
|
||||
|
||||
defer func() {
|
||||
switch count {
|
||||
case 0:
|
||||
log.Debug(capture.Time(start, fmt.Sprintf("media: created no new thumbnails for %s", m.BasePrefix(false))))
|
||||
default:
|
||||
log.Info(capture.Time(start, fmt.Sprintf("media: created %s for %s", english.Plural(count, "thumbnail", "thumbnails"), m.BasePrefix(false))))
|
||||
}
|
||||
}()
|
||||
|
||||
hash := m.Hash()
|
||||
|
||||
var originalImg image.Image
|
||||
var sourceImg image.Image
|
||||
var sourceName thumb.Name
|
||||
|
||||
for _, name := range thumb.DefaultSizes {
|
||||
size := thumb.Sizes[name]
|
||||
|
||||
if size.Uncached() {
|
||||
// Skip, exceeds pre-cached size limit.
|
||||
continue
|
||||
}
|
||||
|
||||
if fileName, err := thumb.FileName(hash, thumbPath, size.Width, size.Height, size.Options...); err != nil {
|
||||
log.Errorf("media: failed creating %s (%s)", clean.Log(string(name)), err)
|
||||
|
||||
return err
|
||||
} else {
|
||||
if !force && fs.FileExists(fileName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if originalImg == nil {
|
||||
img, err := thumb.Open(m.FileName(), m.Orientation())
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("media: %s in %s", err.Error(), clean.Log(m.BaseName()))
|
||||
return err
|
||||
}
|
||||
|
||||
originalImg = img
|
||||
}
|
||||
|
||||
if size.Source != "" {
|
||||
if size.Source == sourceName && sourceImg != nil {
|
||||
_, err = thumb.Create(sourceImg, fileName, size.Width, size.Height, size.Options...)
|
||||
} else {
|
||||
_, err = thumb.Create(originalImg, fileName, size.Width, size.Height, size.Options...)
|
||||
}
|
||||
} else {
|
||||
sourceImg, err = thumb.Create(originalImg, fileName, size.Width, size.Height, size.Options...)
|
||||
sourceName = name
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("media: failed creating %s (%s)", clean.Log(string(name)), err)
|
||||
return err
|
||||
}
|
||||
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameSidecars moves related sidecar files.
|
||||
func (m *MediaFile) RenameSidecars(oldFileName string) (renamed map[string]string, err error) {
|
||||
renamed = make(map[string]string)
|
||||
|
|
@ -1253,8 +1170,16 @@ func (m *MediaFile) ColorProfile() string {
|
|||
m.fileMutex.Lock()
|
||||
defer m.fileMutex.Unlock()
|
||||
|
||||
var err error
|
||||
fileName := m.FileName()
|
||||
|
||||
// Resolve symlinks.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return m.colorProfile
|
||||
}
|
||||
|
||||
// Open file.
|
||||
fileReader, err := os.Open(m.FileName())
|
||||
fileReader, err := os.Open(fileName)
|
||||
|
||||
if err != nil {
|
||||
m.noColorProfile = true
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -1784,7 +1783,7 @@ func TestMediaFile_Megapixels(t *testing.T) {
|
|||
})
|
||||
t.Run("2018-04-12 19_24_49.mov", func(t *testing.T) {
|
||||
if _, err := NewMediaFile("testdata/2018-04-12 19_24_49.mov"); err != nil {
|
||||
assert.EqualError(t, err, "'testdata/2018-04-12 19_24_49.mov' is empty")
|
||||
assert.ErrorContains(t, err, "testdata/2018-04-12 19_24_49.mov' is empty")
|
||||
} else {
|
||||
t.Errorf("error expected")
|
||||
}
|
||||
|
|
@ -2018,141 +2017,6 @@ func TestMediaFile_Orientation(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_Thumbnail(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
if err := conf.CreateDirectories(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
thumbsPath := conf.CachePath() + "/_tmp"
|
||||
|
||||
defer os.RemoveAll(thumbsPath)
|
||||
|
||||
t.Run("elephants.jpg", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Thumbnail(thumbsPath, "tile_500")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.FileExists(t, thumbnail)
|
||||
})
|
||||
t.Run("invalid image format", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.xmp")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Thumbnail(thumbsPath, "tile_500")
|
||||
|
||||
assert.EqualError(t, err, "media: failed creating thumbnail for canon_eos_6d.xmp (image: unknown format)")
|
||||
|
||||
t.Log(thumbnail)
|
||||
})
|
||||
t.Run("invalid thumbnail type", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Thumbnail(thumbsPath, "invalid_500")
|
||||
|
||||
assert.EqualError(t, err, "media: invalid type invalid_500")
|
||||
|
||||
t.Log(thumbnail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_Resample(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
if err := conf.CreateDirectories(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
thumbsPath := conf.CachePath() + "/_tmp"
|
||||
|
||||
defer os.RemoveAll(thumbsPath)
|
||||
t.Run("elephants.jpg", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Resample(thumbsPath, thumb.Tile500)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, thumbnail)
|
||||
|
||||
})
|
||||
t.Run("invalid type", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Resample(thumbsPath, "xxx_500")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("err should not be nil")
|
||||
}
|
||||
|
||||
assert.Equal(t, "media: invalid type xxx_500", err.Error())
|
||||
assert.Empty(t, thumbnail)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestMediaFile_RenderDefaultThumbs(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
thumbsPath := conf.CachePath() + "/_tmp"
|
||||
|
||||
defer os.RemoveAll(thumbsPath)
|
||||
|
||||
if err := conf.CreateDirectories(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
m, err := NewMediaFile(filepath.Join(conf.ExamplesPath(), "elephants.jpg"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.CreateThumbnails(thumbsPath, true)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbFilename, err := thumb.FileName(m.Hash(), thumbsPath, thumb.Sizes[thumb.Tile50].Width, thumb.Sizes[thumb.Tile50].Height, thumb.Sizes[thumb.Tile50].Options...)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.FileExists(t, thumbFilename)
|
||||
|
||||
err = m.CreateThumbnails(thumbsPath, false)
|
||||
|
||||
assert.Empty(t, err)
|
||||
}
|
||||
|
||||
func TestMediaFile_FileType(t *testing.T) {
|
||||
m, err := NewMediaFile(filepath.Join(conf.ExamplesPath(), "this-is-a-jpeg.png"))
|
||||
|
||||
|
|
|
|||
141
internal/photoprism/mediafile_thumbs.go
Normal file
141
internal/photoprism/mediafile_thumbs.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/dustin/go-humanize/english"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
"github.com/photoprism/photoprism/pkg/capture"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// Bounds returns the media dimensions as image.Rectangle.
|
||||
func (m *MediaFile) Bounds() image.Rectangle {
|
||||
return image.Rectangle{Min: image.Point{}, Max: image.Point{X: m.Width(), Y: m.Height()}}
|
||||
}
|
||||
|
||||
// Thumbnail returns a thumbnail filename.
|
||||
func (m *MediaFile) Thumbnail(path string, sizeName thumb.Name) (filename string, err error) {
|
||||
size, ok := thumb.Sizes[sizeName]
|
||||
|
||||
if !ok {
|
||||
log.Errorf("media: invalid type %s", sizeName)
|
||||
return "", fmt.Errorf("media: invalid type %s", sizeName)
|
||||
}
|
||||
|
||||
// Choose the smallest fitting size if the original image is smaller.
|
||||
if size.Fit && m.Bounds().In(size.Bounds()) {
|
||||
size = thumb.FitBounds(m.Bounds())
|
||||
log.Tracef("media: smallest fitting size for %s is %s (width %d, height %d)", clean.Log(m.RootRelName()), size.Name, size.Width, size.Height)
|
||||
}
|
||||
|
||||
thumbName, err := size.FromFile(m.FileName(), m.Hash(), path, m.Orientation())
|
||||
|
||||
if err != nil {
|
||||
err = fmt.Errorf("media: failed creating thumbnail for %s (%s)", clean.Log(m.BaseName()), err)
|
||||
log.Debug(err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return thumbName, nil
|
||||
}
|
||||
|
||||
// Resample returns a resampled image of the file.
|
||||
func (m *MediaFile) Resample(path string, sizeName thumb.Name) (img image.Image, err error) {
|
||||
thumbName, err := m.Thumbnail(path, sizeName)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return imaging.Open(thumbName)
|
||||
}
|
||||
|
||||
// CreateThumbnails creates the default thumbnail sizes if the media file
|
||||
// is a JPEG and they don't exist yet (except force is true).
|
||||
func (m *MediaFile) CreateThumbnails(thumbPath string, force bool) (err error) {
|
||||
if !m.IsJpeg() {
|
||||
// Skip.
|
||||
return
|
||||
}
|
||||
|
||||
count := 0
|
||||
start := time.Now()
|
||||
|
||||
defer func() {
|
||||
switch count {
|
||||
case 0:
|
||||
log.Debug(capture.Time(start, fmt.Sprintf("media: created no new thumbnails for %s", clean.Log(m.RootRelName()))))
|
||||
default:
|
||||
log.Info(capture.Time(start, fmt.Sprintf("media: created %s for %s", english.Plural(count, "thumbnail", "thumbnails"), clean.Log(m.RootRelName()))))
|
||||
}
|
||||
}()
|
||||
|
||||
hash := m.Hash()
|
||||
|
||||
var original image.Image
|
||||
|
||||
var srcImg image.Image
|
||||
var srcName thumb.Name
|
||||
|
||||
for _, name := range thumb.Names {
|
||||
var size thumb.Size
|
||||
var fileName string
|
||||
|
||||
if size = thumb.Sizes[name]; size.Uncached() {
|
||||
// Skip, exceeds pre-cached size limit.
|
||||
continue
|
||||
} else if fileName, err = size.FileName(hash, thumbPath); err != nil {
|
||||
log.Errorf("media: failed creating %s (%s)", clean.Log(string(name)), err)
|
||||
return err
|
||||
} else if force || !fs.FileExists(fileName) {
|
||||
// Open original if needed.
|
||||
if original == nil {
|
||||
img, err := thumb.Open(m.FileName(), m.Orientation())
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("media: %s in %s", err.Error(), clean.Log(m.RootRelName()))
|
||||
return err
|
||||
}
|
||||
|
||||
original = img
|
||||
|
||||
log.Debugf("media: opened %s [%s]", clean.Log(m.RootRelName()), thumb.MemSize(original).String())
|
||||
}
|
||||
|
||||
// Thumb size too large
|
||||
// for the original image?
|
||||
if size.Skip(original) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reuse existing thumb to improve performance
|
||||
// and reduce server load?
|
||||
if size.Source != "" {
|
||||
if size.Source == srcName && srcImg != nil {
|
||||
_, err = size.Create(srcImg, fileName)
|
||||
} else {
|
||||
_, err = size.Create(original, fileName)
|
||||
}
|
||||
} else {
|
||||
srcImg, err = size.Create(original, fileName)
|
||||
srcName = name
|
||||
}
|
||||
|
||||
// Failed?
|
||||
if err != nil {
|
||||
log.Errorf("media: failed creating %s (%s)", name.String(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
180
internal/photoprism/mediafile_thumbs_test.go
Normal file
180
internal/photoprism/mediafile_thumbs_test.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package photoprism
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
)
|
||||
|
||||
func TestMediaFile_Thumbnail(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
if err := conf.CreateDirectories(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
thumbsPath := conf.CachePath() + "/.test_mediafile_thumbnail"
|
||||
|
||||
defer os.RemoveAll(thumbsPath)
|
||||
|
||||
t.Run("elephants.jpg", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Thumbnail(thumbsPath, "tile_500")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.FileExists(t, thumbnail)
|
||||
})
|
||||
t.Run("invalid image format", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.xmp")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Thumbnail(thumbsPath, "tile_500")
|
||||
|
||||
assert.EqualError(t, err, "media: failed creating thumbnail for canon_eos_6d.xmp (image: unknown format)")
|
||||
|
||||
t.Log(thumbnail)
|
||||
})
|
||||
t.Run("invalid thumbnail type", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Thumbnail(thumbsPath, "invalid_500")
|
||||
|
||||
assert.EqualError(t, err, "media: invalid type invalid_500")
|
||||
|
||||
t.Log(thumbnail)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaFile_Resample(t *testing.T) {
|
||||
conf := config.TestConfig()
|
||||
|
||||
if err := conf.CreateDirectories(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
thumbsPath := conf.CachePath() + "/.test_mediafile_resample"
|
||||
|
||||
defer func(path string) {
|
||||
_ = os.RemoveAll(path)
|
||||
}(thumbsPath)
|
||||
|
||||
t.Run("elephants.jpg", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Resample(thumbsPath, thumb.Tile500)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, thumbnail)
|
||||
|
||||
})
|
||||
t.Run("invalid type", func(t *testing.T) {
|
||||
image, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbnail, err := image.Resample(thumbsPath, "xxx_500")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("err should not be nil")
|
||||
}
|
||||
|
||||
assert.Equal(t, "media: invalid type xxx_500", err.Error())
|
||||
assert.Empty(t, thumbnail)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestMediaFile_CreateThumbnails(t *testing.T) {
|
||||
c := config.TestConfig()
|
||||
|
||||
thumbsPath := "./.test_mediafile_createthumbnails"
|
||||
|
||||
if p, err := filepath.Abs(thumbsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
thumbsPath = p
|
||||
}
|
||||
|
||||
defer func(path string) {
|
||||
_ = os.RemoveAll(path)
|
||||
}(thumbsPath)
|
||||
|
||||
if err := c.CreateDirectories(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("elephants.jpg", func(t *testing.T) {
|
||||
m, err := NewMediaFile(filepath.Join(conf.ExamplesPath(), "elephants.jpg"))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.CreateThumbnails(thumbsPath, true)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbFilename, err := thumb.FileName(m.Hash(), thumbsPath, thumb.Sizes[thumb.Tile50].Width, thumb.Sizes[thumb.Tile50].Height, thumb.Sizes[thumb.Tile50].Options...)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.FileExists(t, thumbFilename)
|
||||
assert.NoError(t, m.CreateThumbnails(thumbsPath, false))
|
||||
})
|
||||
|
||||
t.Run("animated-earth.jpg", func(t *testing.T) {
|
||||
m, err := NewMediaFile("testdata/animated-earth.jpg")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = m.CreateThumbnails(thumbsPath, true)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thumbFilename, err := thumb.FileName(m.Hash(), thumbsPath, thumb.Sizes[thumb.Tile50].Width, thumb.Sizes[thumb.Tile50].Height, thumb.Sizes[thumb.Tile50].Options...)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.FileExists(t, thumbFilename)
|
||||
assert.NoError(t, m.CreateThumbnails(thumbsPath, false))
|
||||
})
|
||||
}
|
||||
|
|
@ -5,16 +5,16 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/face"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/classify"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/face"
|
||||
"github.com/photoprism/photoprism/internal/nsfw"
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResample_Start(t *testing.T) {
|
||||
|
|
@ -78,21 +78,21 @@ func TestThumb_Filename(t *testing.T) {
|
|||
t.FailNow()
|
||||
}
|
||||
|
||||
assert.Equal(t, "resample: file hash is empty or too short (999)", err.Error())
|
||||
assert.Equal(t, "thumb: file hash is empty or too short (999)", err.Error())
|
||||
})
|
||||
t.Run("invalid width", func(t *testing.T) {
|
||||
_, err := thumb.FileName("99988", thumbsPath, -4, 150, thumb.ResampleFit, thumb.ResampleNearestNeighbor)
|
||||
if err == nil {
|
||||
t.FailNow()
|
||||
}
|
||||
assert.Equal(t, "resample: width exceeds limit (-4)", err.Error())
|
||||
assert.Equal(t, "thumb: width exceeds limit (-4)", err.Error())
|
||||
})
|
||||
t.Run("invalid height", func(t *testing.T) {
|
||||
_, err := thumb.FileName("99988", thumbsPath, 200, -1, thumb.ResampleFit, thumb.ResampleNearestNeighbor)
|
||||
if err == nil {
|
||||
t.FailNow()
|
||||
}
|
||||
assert.Equal(t, "resample: height exceeds limit (-1)", err.Error())
|
||||
assert.Equal(t, "thumb: height exceeds limit (-1)", err.Error())
|
||||
})
|
||||
t.Run("empty thumbpath", func(t *testing.T) {
|
||||
path := ""
|
||||
|
|
@ -100,7 +100,7 @@ func TestThumb_Filename(t *testing.T) {
|
|||
if err == nil {
|
||||
t.FailNow()
|
||||
}
|
||||
assert.Equal(t, "resample: folder is empty", err.Error())
|
||||
assert.Equal(t, "thumb: folder is empty", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ func TestThumb_FromFile(t *testing.T) {
|
|||
t.Fatal("err should NOT be nil")
|
||||
}
|
||||
|
||||
assert.Equal(t, "resample: invalid file hash 123", err.Error())
|
||||
assert.Equal(t, "thumb: invalid file hash 123", err.Error())
|
||||
})
|
||||
t.Run("filename too short", func(t *testing.T) {
|
||||
file := &entity.File{
|
||||
|
|
@ -147,7 +147,7 @@ func TestThumb_FromFile(t *testing.T) {
|
|||
}
|
||||
|
||||
if _, err := thumb.FromFile(file.FileName, file.FileHash, thumbsPath, 224, 224, file.FileOrientation); err != nil {
|
||||
assert.Equal(t, "resample: invalid file name xxx", err.Error())
|
||||
assert.Equal(t, "thumb: invalid file name xxx", err.Error())
|
||||
} else {
|
||||
t.Error("error is nil")
|
||||
}
|
||||
|
|
@ -241,7 +241,7 @@ func TestThumb_Create(t *testing.T) {
|
|||
|
||||
thumbnail := res
|
||||
|
||||
assert.Equal(t, "resample: width has an invalid value (-1)", err.Error())
|
||||
assert.Equal(t, "thumb: width has an invalid value (-1)", err.Error())
|
||||
bounds := thumbnail.Bounds()
|
||||
assert.NotEqual(t, 150, bounds.Dx())
|
||||
})
|
||||
|
|
@ -267,7 +267,7 @@ func TestThumb_Create(t *testing.T) {
|
|||
|
||||
thumbnail := res
|
||||
|
||||
assert.Equal(t, "resample: height has an invalid value (-1)", err.Error())
|
||||
assert.Equal(t, "thumb: height has an invalid value (-1)", err.Error())
|
||||
bounds := thumbnail.Bounds()
|
||||
assert.NotEqual(t, 150, bounds.Dx())
|
||||
})
|
||||
|
|
|
|||
BIN
internal/photoprism/testdata/animated-earth.jpg
vendored
Normal file
BIN
internal/photoprism/testdata/animated-earth.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -29,15 +29,15 @@ func (photo Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken
|
|||
DownloadUrl: viewer.DownloadUrl(photo.FileHash, apiUri, downloadToken),
|
||||
Width: photo.FileWidth,
|
||||
Height: photo.FileHeight,
|
||||
Thumbs: viewer.Thumbs{
|
||||
Fit720: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken),
|
||||
Fit1280: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken),
|
||||
Fit1920: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken),
|
||||
Fit2048: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken),
|
||||
Fit2560: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken),
|
||||
Fit3840: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken),
|
||||
Fit4096: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken),
|
||||
Fit7680: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken),
|
||||
Thumbs: thumb.Public{
|
||||
Fit720: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken),
|
||||
Fit1280: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken),
|
||||
Fit1920: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken),
|
||||
Fit2048: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken),
|
||||
Fit2560: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken),
|
||||
Fit3840: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken),
|
||||
Fit4096: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken),
|
||||
Fit7680: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -70,15 +70,15 @@ func (photo GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadTo
|
|||
DownloadUrl: viewer.DownloadUrl(photo.FileHash, apiUri, downloadToken),
|
||||
Width: photo.FileWidth,
|
||||
Height: photo.FileHeight,
|
||||
Thumbs: viewer.Thumbs{
|
||||
Fit720: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken),
|
||||
Fit1280: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken),
|
||||
Fit1920: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken),
|
||||
Fit2048: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken),
|
||||
Fit2560: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken),
|
||||
Fit3840: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken),
|
||||
Fit4096: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken),
|
||||
Fit7680: viewer.NewThumb(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken),
|
||||
Thumbs: thumb.Public{
|
||||
Fit720: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit720], contentUri, previewToken),
|
||||
Fit1280: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken),
|
||||
Fit1920: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit1920], contentUri, previewToken),
|
||||
Fit2048: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2048], contentUri, previewToken),
|
||||
Fit2560: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit2560], contentUri, previewToken),
|
||||
Fit3840: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken),
|
||||
Fit4096: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit4096], contentUri, previewToken),
|
||||
Fit7680: thumb.New(photo.FileWidth, photo.FileHeight, photo.FileHash, thumb.Sizes[thumb.Fit7680], contentUri, previewToken),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,22 +24,22 @@ func Suffix(width, height int, opts ...ResampleOption) (result string) {
|
|||
return result
|
||||
}
|
||||
|
||||
// FileName returns the thumb cache file name based on path, size, and options.
|
||||
func FileName(hash string, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
|
||||
// FileName returns the file name of the thumbnail for the matching size.
|
||||
func FileName(hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
|
||||
if InvalidSize(width) {
|
||||
return "", fmt.Errorf("resample: width exceeds limit (%d)", width)
|
||||
return "", fmt.Errorf("thumb: width exceeds limit (%d)", width)
|
||||
}
|
||||
|
||||
if InvalidSize(height) {
|
||||
return "", fmt.Errorf("resample: height exceeds limit (%d)", height)
|
||||
return "", fmt.Errorf("thumb: height exceeds limit (%d)", height)
|
||||
}
|
||||
|
||||
if len(hash) < 4 {
|
||||
return "", fmt.Errorf("resample: file hash is empty or too short (%s)", clean.Log(hash))
|
||||
return "", fmt.Errorf("thumb: file hash is empty or too short (%s)", clean.Log(hash))
|
||||
}
|
||||
|
||||
if len(thumbPath) == 0 {
|
||||
return "", errors.New("resample: folder is empty")
|
||||
return "", errors.New("thumb: folder is empty")
|
||||
}
|
||||
|
||||
suffix := Suffix(width, height, opts...)
|
||||
|
|
@ -54,35 +54,42 @@ func FileName(hash string, thumbPath string, width, height int, opts ...Resample
|
|||
return fileName, nil
|
||||
}
|
||||
|
||||
// FromCache returns the thumb cache file name for an image.
|
||||
// ResolvedName returns the file name of the thumbnail for the matching size with all symlinks resolved.
|
||||
func ResolvedName(hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
|
||||
if fileName, err = FileName(hash, thumbPath, width, height, opts...); err != nil {
|
||||
return fileName, err
|
||||
} else {
|
||||
return fs.Resolve(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// FromCache returns the filename if a thumbnail image with the matching size is in the cache.
|
||||
func FromCache(imageFilename, hash, thumbPath string, width, height int, opts ...ResampleOption) (fileName string, err error) {
|
||||
if len(hash) < 4 {
|
||||
return "", fmt.Errorf("resample: invalid file hash %s", clean.Log(hash))
|
||||
return "", fmt.Errorf("thumb: invalid file hash %s", clean.Log(hash))
|
||||
}
|
||||
|
||||
if len(imageFilename) < 4 {
|
||||
return "", fmt.Errorf("resample: invalid file name %s", clean.Log(imageFilename))
|
||||
return "", fmt.Errorf("thumb: invalid file name %s", clean.Log(imageFilename))
|
||||
}
|
||||
|
||||
fileName, err = FileName(hash, thumbPath, width, height, opts...)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
if fileName, err = FileName(hash, thumbPath, width, height, opts...); err != nil {
|
||||
log.Debugf("thumb: %s in %s (get filename)", err, clean.Log(imageFilename))
|
||||
return "", err
|
||||
}
|
||||
|
||||
if fs.FileExists(fileName) {
|
||||
} else if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return "", ErrNotCached
|
||||
} else if fs.FileExists(fileName) {
|
||||
return fileName, nil
|
||||
}
|
||||
|
||||
return "", ErrThumbNotCached
|
||||
return "", ErrNotCached
|
||||
}
|
||||
|
||||
// FromFile returns the thumb cache file name for an image, and creates it if needed.
|
||||
// FromFile creates a new thumbnail with the specified size if it was not found in the cache, and returns the filename.
|
||||
func FromFile(imageFilename, hash, thumbPath string, width, height, orientation int, opts ...ResampleOption) (fileName string, err error) {
|
||||
if fileName, err := FromCache(imageFilename, hash, thumbPath, width, height, opts...); err == nil {
|
||||
if fileName, err = FromCache(imageFilename, hash, thumbPath, width, height, opts...); err == nil {
|
||||
return fileName, err
|
||||
} else if err != ErrThumbNotCached {
|
||||
} else if err != ErrNotCached {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
|
@ -98,12 +105,12 @@ func FromFile(imageFilename, hash, thumbPath string, width, height, orientation
|
|||
img, err := Open(imageFilename, orientation)
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("resample: %s in %s", err, clean.Log(filepath.Base(imageFilename)))
|
||||
log.Debugf("thumb: %s in %s", err, clean.Log(filepath.Base(imageFilename)))
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create thumb from image.
|
||||
if _, err := Create(img, fileName, width, height, opts...); err != nil {
|
||||
if _, err = Create(img, fileName, width, height, opts...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
|
@ -113,11 +120,11 @@ func FromFile(imageFilename, hash, thumbPath string, width, height, orientation
|
|||
// Create creates an image thumbnail.
|
||||
func Create(img image.Image, fileName string, width, height int, opts ...ResampleOption) (result image.Image, err error) {
|
||||
if InvalidSize(width) {
|
||||
return img, fmt.Errorf("resample: width has an invalid value (%d)", width)
|
||||
return img, fmt.Errorf("thumb: width has an invalid value (%d)", width)
|
||||
}
|
||||
|
||||
if InvalidSize(height) {
|
||||
return img, fmt.Errorf("resample: height has an invalid value (%d)", height)
|
||||
return img, fmt.Errorf("thumb: height has an invalid value (%d)", height)
|
||||
}
|
||||
|
||||
result = Resample(img, width, height, opts...)
|
||||
|
|
@ -135,7 +142,7 @@ func Create(img image.Image, fileName string, width, height int, opts ...Resampl
|
|||
err = imaging.Save(result, fileName, quality)
|
||||
|
||||
if err != nil {
|
||||
log.Debugf("resample: failed to save %s", clean.Log(filepath.Base(fileName)))
|
||||
log.Debugf("thumb: failed to save %s", clean.Log(filepath.Base(fileName)))
|
||||
return result, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package thumb
|
|||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
|
|
@ -177,7 +178,7 @@ func TestFileName(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
assert.Equal(t, "resample: width exceeds limit (-2)", err.Error())
|
||||
assert.Equal(t, "thumb: width exceeds limit (-2)", err.Error())
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("invalid height", func(t *testing.T) {
|
||||
|
|
@ -188,7 +189,8 @@ func TestFileName(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
assert.Equal(t, "resample: height exceeds limit (-3)", err.Error())
|
||||
|
||||
assert.Equal(t, "thumb: height exceeds limit (-3)", err.Error())
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("invalid hash", func(t *testing.T) {
|
||||
|
|
@ -199,7 +201,8 @@ func TestFileName(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
assert.Equal(t, "resample: file hash is empty or too short (12)", err.Error())
|
||||
|
||||
assert.Equal(t, "thumb: file hash is empty or too short (12)", err.Error())
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("invalid thumb path", func(t *testing.T) {
|
||||
|
|
@ -210,7 +213,75 @@ func TestFileName(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
assert.Equal(t, "resample: folder is empty", err.Error())
|
||||
|
||||
assert.Equal(t, "thumb: folder is empty", err.Error())
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolvedName(t *testing.T) {
|
||||
t.Run("colors", func(t *testing.T) {
|
||||
colorThumb := Sizes[Colors]
|
||||
|
||||
result, err := ResolvedName("123456789098765432", "testdata", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", result)
|
||||
})
|
||||
|
||||
t.Run("fit_720", func(t *testing.T) {
|
||||
fit720 := Sizes[Fit720]
|
||||
|
||||
result, err := ResolvedName("123456789098765432", "testdata", fit720.Width, fit720.Height, fit720.Options...)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", result)
|
||||
})
|
||||
t.Run("invalid width", func(t *testing.T) {
|
||||
colorThumb := Sizes[Colors]
|
||||
|
||||
result, err := ResolvedName("123456789098765432", "testdata", -2, colorThumb.Height, colorThumb.Options...)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
assert.Equal(t, "thumb: width exceeds limit (-2)", err.Error())
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("invalid height", func(t *testing.T) {
|
||||
colorThumb := Sizes[Colors]
|
||||
|
||||
result, err := ResolvedName("123456789098765432", "testdata", colorThumb.Width, -3, colorThumb.Options...)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
|
||||
assert.Equal(t, "thumb: height exceeds limit (-3)", err.Error())
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("invalid hash", func(t *testing.T) {
|
||||
colorThumb := Sizes[Colors]
|
||||
|
||||
result, err := ResolvedName("12", "testdata", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
|
||||
assert.Equal(t, "thumb: file hash is empty or too short (12)", err.Error())
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
t.Run("invalid thumb path", func(t *testing.T) {
|
||||
colorThumb := Sizes[Colors]
|
||||
|
||||
result, err := ResolvedName("123456789098765432", "", colorThumb.Width, colorThumb.Height, colorThumb.Options...)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
|
||||
assert.Equal(t, "thumb: folder is empty", err.Error())
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
}
|
||||
|
|
@ -230,7 +301,6 @@ func TestFromFile(t *testing.T) {
|
|||
}
|
||||
|
||||
assert.Equal(t, dst, fileName)
|
||||
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
|
||||
|
|
@ -247,8 +317,7 @@ func TestFromFile(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, dst, fileName)
|
||||
|
||||
assert.Truef(t, strings.HasSuffix(fileName, dst), fileName, dst)
|
||||
assert.FileExists(t, dst)
|
||||
})
|
||||
|
||||
|
|
@ -272,7 +341,7 @@ func TestFromFile(t *testing.T) {
|
|||
t.Fatal("error expected")
|
||||
}
|
||||
assert.Equal(t, "", fileName)
|
||||
assert.Equal(t, "resample: invalid file name ''", err.Error())
|
||||
assert.Equal(t, "thumb: invalid file name ''", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -287,8 +356,8 @@ func TestFromCache(t *testing.T) {
|
|||
|
||||
assert.Equal(t, "", fileName)
|
||||
|
||||
if err != ErrThumbNotCached {
|
||||
t.Fatal("ErrThumbNotCached expected")
|
||||
if err != ErrNotCached {
|
||||
t.Fatal("ErrNotCached expected")
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -314,7 +383,8 @@ func TestFromCache(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
assert.Equal(t, "resample: invalid file hash 12", err.Error())
|
||||
|
||||
assert.Equal(t, "thumb: invalid file hash 12", err.Error())
|
||||
assert.Empty(t, fileName)
|
||||
})
|
||||
t.Run("empty filename", func(t *testing.T) {
|
||||
|
|
@ -325,7 +395,8 @@ func TestFromCache(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("error expected")
|
||||
}
|
||||
assert.Equal(t, "resample: invalid file name ''", err.Error())
|
||||
|
||||
assert.Equal(t, "thumb: invalid file name ''", err.Error())
|
||||
assert.Empty(t, fileName)
|
||||
})
|
||||
}
|
||||
|
|
@ -430,7 +501,7 @@ func TestCreate(t *testing.T) {
|
|||
t.Fatal("error expected")
|
||||
}
|
||||
|
||||
assert.Equal(t, "resample: width has an invalid value (-5)", err.Error())
|
||||
assert.Equal(t, "thumb: width has an invalid value (-5)", err.Error())
|
||||
t.Log(resized)
|
||||
})
|
||||
t.Run("invalid height", func(t *testing.T) {
|
||||
|
|
@ -458,7 +529,7 @@ func TestCreate(t *testing.T) {
|
|||
t.Fatal("error expected")
|
||||
}
|
||||
|
||||
assert.Equal(t, "resample: height has an invalid value (-3)", err.Error())
|
||||
t.Log(resized)
|
||||
assert.Equal(t, "thumb: height has an invalid value (-3)", err.Error())
|
||||
assert.NotNil(t, resized)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrThumbNotCached = errors.New("thumbnail not cached")
|
||||
ErrNotCached = errors.New("not cached")
|
||||
)
|
||||
|
|
|
|||
34
internal/thumb/fit.go
Normal file
34
internal/thumb/fit.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package thumb
|
||||
|
||||
import "image"
|
||||
|
||||
// Fitted contains only "fit" cropped thumbnail sizes from largest to smallest.
|
||||
// Best for the viewer as proportional resizing maintains the aspect ratio.
|
||||
var Fitted = []Size{
|
||||
Sizes[Fit7680],
|
||||
Sizes[Fit4096],
|
||||
Sizes[Fit3840],
|
||||
Sizes[Fit2560],
|
||||
Sizes[Fit2048],
|
||||
Sizes[Fit1920],
|
||||
Sizes[Fit1280],
|
||||
Sizes[Fit720],
|
||||
}
|
||||
|
||||
// Fit returns the largest fitting thumbnail size.
|
||||
func Fit(w, h int) (size Size) {
|
||||
j := len(Fitted) - 1
|
||||
|
||||
for i := j; i >= 0; i-- {
|
||||
if size = Fitted[i]; w <= size.Width && h <= size.Height {
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
return Fitted[0]
|
||||
}
|
||||
|
||||
// FitBounds returns the largest thumbnail size fitting the rectangle.
|
||||
func FitBounds(r image.Rectangle) (s Size) {
|
||||
return Fit(r.Dx(), r.Dy())
|
||||
}
|
||||
82
internal/thumb/fit_test.go
Normal file
82
internal/thumb/fit_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package thumb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFit(t *testing.T) {
|
||||
assert.Equal(t, Sizes[Fit720], Fit(54, 453))
|
||||
assert.Equal(t, Sizes[Fit1280], Fit(1000, 1000))
|
||||
assert.Equal(t, Sizes[Fit1280], Fit(1250, 1000))
|
||||
assert.Equal(t, Sizes[Fit2048], Fit(1300, 1300))
|
||||
assert.Equal(t, Sizes[Fit2048], Fit(1600, 1600))
|
||||
assert.Equal(t, Sizes[Fit4096], Fit(1000, 3000))
|
||||
assert.Equal(t, Sizes[Fit3840], Fit(2300, 2000))
|
||||
}
|
||||
|
||||
func TestFitBounds(t *testing.T) {
|
||||
t.Run("example.jpg", func(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
|
||||
assert.Equal(t, 750, bounds.Max.X)
|
||||
assert.Equal(t, 500, bounds.Max.Y)
|
||||
|
||||
size := FitBounds(img.Bounds())
|
||||
|
||||
assert.Equal(t, "fit_1280", size.Name.String())
|
||||
})
|
||||
t.Run("example.bmp", func(t *testing.T) {
|
||||
src := "testdata/example.bmp"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
|
||||
assert.Equal(t, 100, bounds.Max.X)
|
||||
assert.Equal(t, 67, bounds.Max.Y)
|
||||
|
||||
size := FitBounds(img.Bounds())
|
||||
|
||||
assert.Equal(t, "fit_720", size.Name.String())
|
||||
})
|
||||
t.Run("animated-earth.jpg", func(t *testing.T) {
|
||||
src := "testdata/animated-earth.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
|
||||
assert.Equal(t, 300, bounds.Max.X)
|
||||
assert.Equal(t, 300, bounds.Max.Y)
|
||||
|
||||
size := FitBounds(img.Bounds())
|
||||
|
||||
assert.Equal(t, "fit_720", size.Name.String())
|
||||
})
|
||||
}
|
||||
|
|
@ -5,27 +5,42 @@ import (
|
|||
"path/filepath"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// Jpeg converts an image to jpeg, saves and returns it.
|
||||
func Jpeg(srcFilename, jpgFilename string, orientation int) (img image.Image, err error) {
|
||||
img, err = imaging.Open(srcFilename)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("resample: cannot open %s", clean.Log(filepath.Base(srcFilename)))
|
||||
// Resolve symlinks.
|
||||
if srcFilename, err = fs.Resolve(srcFilename); err != nil {
|
||||
log.Debugf("jpeg: %s in %s (resolve source image filename)", err, clean.Log(srcFilename))
|
||||
return img, err
|
||||
}
|
||||
|
||||
// Open source image.
|
||||
img, err = imaging.Open(srcFilename)
|
||||
|
||||
// Failed?
|
||||
if err != nil {
|
||||
log.Errorf("jpeg: cannot open source image %s", clean.Log(filepath.Base(srcFilename)))
|
||||
return img, err
|
||||
}
|
||||
|
||||
// Adjust orientation.
|
||||
if orientation > 1 {
|
||||
img = Rotate(img, orientation)
|
||||
}
|
||||
|
||||
// Get JPEG quality setting.
|
||||
quality := JpegQuality.EncodeOption()
|
||||
|
||||
// Save JPEG file.
|
||||
if err = imaging.Save(img, jpgFilename, quality); err != nil {
|
||||
log.Errorf("resample: failed to save %s", clean.Log(filepath.Base(jpgFilename)))
|
||||
log.Errorf("jpeg: failed to save %s", clean.Log(filepath.Base(jpgFilename)))
|
||||
return img, err
|
||||
}
|
||||
|
||||
// Return JPEG image.
|
||||
return img, nil
|
||||
}
|
||||
|
|
|
|||
61
internal/thumb/memsize.go
Normal file
61
internal/thumb/memsize.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package thumb
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
// Byte size factors.
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
)
|
||||
|
||||
// Bytes represents memory usage in bytes.
|
||||
type Bytes uint64
|
||||
|
||||
// KByte returns the size in kilobyte.
|
||||
func (b Bytes) KByte() float64 {
|
||||
return float64(b) / KB
|
||||
}
|
||||
|
||||
// MByte returns the size in megabyte.
|
||||
func (b Bytes) MByte() float64 {
|
||||
return float64(b) / MB
|
||||
}
|
||||
|
||||
// GByte returns the size in gigabyte.
|
||||
func (b Bytes) GByte() float64 {
|
||||
return float64(b) / GB
|
||||
}
|
||||
|
||||
// String returns a human-readable memory usage string.
|
||||
func (b Bytes) String() string {
|
||||
return humanize.Bytes(uint64(b))
|
||||
}
|
||||
|
||||
// MemSize returns the estimated size of the image in memory in bytes.
|
||||
func MemSize(img image.Image) Bytes {
|
||||
r := img.Bounds()
|
||||
|
||||
pixels := r.Dx() * r.Dy()
|
||||
bytesPerPixel := 4
|
||||
|
||||
// Image representation in a computer memory:
|
||||
// https://medium.com/@oleg.shipitko/what-does-stride-mean-in-image-processing-bba158a72bcd
|
||||
switch img.ColorModel() {
|
||||
case color.AlphaModel, color.GrayModel:
|
||||
bytesPerPixel = 1
|
||||
case color.Alpha16Model, color.Gray16Model:
|
||||
bytesPerPixel = 2
|
||||
case color.RGBAModel, color.NRGBAModel:
|
||||
bytesPerPixel = 4
|
||||
case color.RGBA64Model, color.NRGBA64Model:
|
||||
bytesPerPixel = 8
|
||||
}
|
||||
|
||||
return Bytes(pixels * bytesPerPixel)
|
||||
}
|
||||
26
internal/thumb/memsize_test.go
Normal file
26
internal/thumb/memsize_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package thumb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMemSize(t *testing.T) {
|
||||
src := "testdata/example.jpg"
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := MemSize(img)
|
||||
|
||||
assert.InEpsilon(t, 1464, result.KByte(), 1)
|
||||
assert.InEpsilon(t, 1.430511474, result.MByte(), 0.1)
|
||||
assert.Equal(t, "1.5 MB", result.String())
|
||||
}
|
||||
|
|
@ -33,3 +33,35 @@ const (
|
|||
Fit4096 Name = "fit_4096"
|
||||
Fit7680 Name = "fit_7680"
|
||||
)
|
||||
|
||||
// Names contains all default size names.
|
||||
var Names = []Name{
|
||||
Fit7680,
|
||||
Fit4096,
|
||||
Fit3840,
|
||||
Fit2560,
|
||||
Fit2048,
|
||||
Fit1920,
|
||||
Fit1280,
|
||||
Fit720,
|
||||
Right224,
|
||||
Left224,
|
||||
Colors,
|
||||
Tile500,
|
||||
Tile224,
|
||||
Tile100,
|
||||
Tile50,
|
||||
}
|
||||
|
||||
// Find returns the largest default thumbnail type for the given size limit.
|
||||
func Find(limit int) (name Name, size Size) {
|
||||
for _, name = range Names {
|
||||
t := Sizes[name]
|
||||
|
||||
if t.Width <= limit && t.Height <= limit {
|
||||
return name, t
|
||||
}
|
||||
}
|
||||
|
||||
return "", Size{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,3 +11,19 @@ func TestName_Jpeg(t *testing.T) {
|
|||
assert.Equal(t, "tile_50.jpg", Tile50.Jpeg())
|
||||
})
|
||||
}
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
t.Run("2048", func(t *testing.T) {
|
||||
name, size := Find(2048)
|
||||
assert.Equal(t, Fit2048, name)
|
||||
assert.Equal(t, 2048, size.Width)
|
||||
assert.Equal(t, 2048, size.Height)
|
||||
})
|
||||
|
||||
t.Run("2000", func(t *testing.T) {
|
||||
name, size := Find(2000)
|
||||
assert.Equal(t, Fit1920, name)
|
||||
assert.Equal(t, 1920, size.Width)
|
||||
assert.Equal(t, 1200, size.Height)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"image"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
|
|
@ -13,10 +14,16 @@ var StandardRGB = true
|
|||
|
||||
// Open loads an image from disk, rotates it, and converts the color profile if necessary.
|
||||
func Open(fileName string, orientation int) (result image.Image, err error) {
|
||||
// Filename missing?
|
||||
if fileName == "" {
|
||||
return result, fmt.Errorf("filename missing")
|
||||
}
|
||||
|
||||
// Resolve symlinks.
|
||||
if fileName, err = fs.Resolve(fileName); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Open JPEG?
|
||||
if StandardRGB && fs.FileType(fileName) == fs.ImageJPEG {
|
||||
return OpenJpeg(fileName, orientation)
|
||||
|
|
@ -29,7 +36,7 @@ func Open(fileName string, orientation int) (result image.Image, err error) {
|
|||
return result, err
|
||||
}
|
||||
|
||||
// Rotate?
|
||||
// Adjust orientation.
|
||||
if orientation > 1 {
|
||||
img = Rotate(img, orientation)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func OpenJpeg(fileName string, orientation int) (result image.Image, err error)
|
|||
var img image.Image
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("resample: %s in %s (read color metadata)", err, logName)
|
||||
log.Warnf("thumb: %s in %s (read color metadata)", err, logName)
|
||||
img, err = imaging.Decode(fileReader)
|
||||
} else {
|
||||
img, err = imaging.Decode(imgStream)
|
||||
|
|
@ -59,9 +59,9 @@ func OpenJpeg(fileName string, orientation int) (result image.Image, err error)
|
|||
if md != nil {
|
||||
if iccProfile, err := md.ICCProfile(); err != nil || iccProfile == nil {
|
||||
// Do nothing.
|
||||
log.Tracef("resample: %s has no color profile", logName)
|
||||
log.Tracef("thumb: %s has no color profile", logName)
|
||||
} else if profile, err := iccProfile.Description(); err == nil && profile != "" {
|
||||
log.Tracef("resample: %s has color profile %s", logName, clean.Log(profile))
|
||||
log.Tracef("thumb: %s has color profile %s", logName, clean.Log(profile))
|
||||
switch {
|
||||
case colors.ProfileDisplayP3.Equal(profile):
|
||||
img = colors.ToSRGB(img, colors.ProfileDisplayP3)
|
||||
|
|
@ -69,7 +69,7 @@ func OpenJpeg(fileName string, orientation int) (result image.Image, err error)
|
|||
}
|
||||
}
|
||||
|
||||
// Rotate?
|
||||
// Adjust orientation.
|
||||
if orientation > 1 {
|
||||
img = Rotate(img, orientation)
|
||||
}
|
||||
|
|
|
|||
13
internal/thumb/public.go
Normal file
13
internal/thumb/public.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package thumb
|
||||
|
||||
// Public represents public thumbnail URLs with dimensions.
|
||||
type Public struct {
|
||||
Fit720 Thumb `json:"fit_720"`
|
||||
Fit1280 Thumb `json:"fit_1280"`
|
||||
Fit1920 Thumb `json:"fit_1920"`
|
||||
Fit2048 Thumb `json:"fit_2048"`
|
||||
Fit2560 Thumb `json:"fit_2560"`
|
||||
Fit3840 Thumb `json:"fit_3840"`
|
||||
Fit4096 Thumb `json:"fit_4096"`
|
||||
Fit7680 Thumb `json:"fit_7680"`
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ func Rotate(img image.Image, o int) image.Image {
|
|||
case OrientationTransverse:
|
||||
img = imaging.Transverse(img)
|
||||
default:
|
||||
log.Debugf("rotate: invalid orientation %d", o)
|
||||
log.Debugf("thumb: invalid orientation %d (rotate)", o)
|
||||
}
|
||||
|
||||
return img
|
||||
|
|
|
|||
67
internal/thumb/size.go
Normal file
67
internal/thumb/size.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package thumb
|
||||
|
||||
import (
|
||||
"image"
|
||||
)
|
||||
|
||||
type Size struct {
|
||||
Name Name `json:"name"`
|
||||
Source Name `json:"-"`
|
||||
Use string `json:"use"`
|
||||
Width int `json:"w"`
|
||||
Height int `json:"h"`
|
||||
Public bool `json:"-"`
|
||||
Fit bool `json:"-"`
|
||||
Options []ResampleOption `json:"-"`
|
||||
}
|
||||
|
||||
// Bounds returns the thumb size as image.Rectangle.
|
||||
func (s Size) Bounds() image.Rectangle {
|
||||
return image.Rectangle{Min: image.Point{}, Max: image.Point{X: s.Width, Y: s.Height}}
|
||||
}
|
||||
|
||||
// Uncached tests if thumbnail type exceeds the cached thumbnails size limit.
|
||||
func (s Size) Uncached() bool {
|
||||
return s.Width > SizePrecached || s.Height > SizePrecached
|
||||
}
|
||||
|
||||
// ExceedsLimit tests if thumbnail type is too large, and can not be rendered at all.
|
||||
func (s Size) ExceedsLimit() bool {
|
||||
return s.Width > MaxSize() || s.Height > MaxSize()
|
||||
}
|
||||
|
||||
// FromCache returns the filename if a thumbnail image with the matching size is in the cache.
|
||||
func (s Size) FromCache(fileName, fileHash, cachePath string) (string, error) {
|
||||
return FromCache(fileName, fileHash, cachePath, s.Width, s.Height, s.Options...)
|
||||
}
|
||||
|
||||
// FromFile creates a new thumbnail with the matching size if it was not found in the cache, and returns the filename.
|
||||
func (s Size) FromFile(fileName, fileHash, cachePath string, fileOrientation int) (string, error) {
|
||||
return FromFile(fileName, fileHash, cachePath, s.Width, s.Height, fileOrientation, s.Options...)
|
||||
}
|
||||
|
||||
// Create creates a thumbnail with the matching size and returns it as image.Image.
|
||||
func (s Size) Create(img image.Image, fileName string) (image.Image, error) {
|
||||
return Create(img, fileName, s.Width, s.Height, s.Options...)
|
||||
}
|
||||
|
||||
// FileName returns the file name of the thumbnail for the matching size.
|
||||
func (s Size) FileName(hash, thumbPath string) (string, error) {
|
||||
return FileName(hash, thumbPath, s.Width, s.Height, s.Options...)
|
||||
}
|
||||
|
||||
// ResolvedName returns the file name of the thumbnail for the matching size with all symlinks resolved.
|
||||
func (s Size) ResolvedName(hash, thumbPath string) (string, error) {
|
||||
return ResolvedName(hash, thumbPath, s.Width, s.Height, s.Options...)
|
||||
}
|
||||
|
||||
// Skip checks if the thumbnail size is too large for the image and can be skipped.
|
||||
func (s Size) Skip(img image.Image) bool {
|
||||
if !s.Fit || !img.Bounds().In(s.Bounds()) {
|
||||
return false
|
||||
} else if newSize := FitBounds(img.Bounds()); newSize.Width < s.Width {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
79
internal/thumb/size_test.go
Normal file
79
internal/thumb/size_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
package thumb
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSize_Skip(t *testing.T) {
|
||||
// Image Size: 750x500px
|
||||
src := "testdata/example.jpg"
|
||||
|
||||
t.Run("Tile500", func(t *testing.T) {
|
||||
size := Sizes[Tile500]
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.False(t, size.Skip(img))
|
||||
})
|
||||
t.Run("Fit720", func(t *testing.T) {
|
||||
size := Sizes[Fit720]
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.False(t, size.Skip(img))
|
||||
})
|
||||
t.Run("Fit1280", func(t *testing.T) {
|
||||
size := Sizes[Fit1280]
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.False(t, size.Skip(img))
|
||||
})
|
||||
t.Run("Fit2048", func(t *testing.T) {
|
||||
size := Sizes[Fit2048]
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.True(t, size.Skip(img))
|
||||
})
|
||||
t.Run("Fit4096", func(t *testing.T) {
|
||||
size := Sizes[Fit4096]
|
||||
|
||||
assert.FileExists(t, src)
|
||||
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.True(t, size.Skip(img))
|
||||
})
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ var (
|
|||
Filter = ResampleLanczos
|
||||
)
|
||||
|
||||
// MaxSize returns the max supported thumb size in pixels.
|
||||
func MaxSize() int {
|
||||
if SizePrecached > SizeUncached {
|
||||
return SizePrecached
|
||||
|
|
@ -14,78 +15,29 @@ func MaxSize() int {
|
|||
return SizeUncached
|
||||
}
|
||||
|
||||
// InvalidSize tests if the thumb size in pixels is invalid.
|
||||
func InvalidSize(size int) bool {
|
||||
return size < 0 || size > MaxSize()
|
||||
}
|
||||
|
||||
type Size struct {
|
||||
Name Name `json:"name"`
|
||||
Source Name `json:"-"`
|
||||
Use string `json:"use"`
|
||||
Width int `json:"w"`
|
||||
Height int `json:"h"`
|
||||
Public bool `json:"-"`
|
||||
Options []ResampleOption `json:"-"`
|
||||
}
|
||||
|
||||
// SizeMap maps size names to sizes.
|
||||
type SizeMap map[Name]Size
|
||||
|
||||
// Sizes contains the properties of all thumbnail sizes.
|
||||
var Sizes = SizeMap{
|
||||
Tile50: {Tile50, Tile500, "Lists", 50, 50, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
Tile100: {Tile100, Tile500, "Maps", 100, 100, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
Tile224: {Tile224, Tile500, "TensorFlow, Mosaic", 224, 224, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
Tile500: {Tile500, "", "Tiles", 500, 500, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
Colors: {Colors, Fit720, "Color Detection", 3, 3, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}},
|
||||
Left224: {Left224, Fit720, "TensorFlow", 224, 224, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}},
|
||||
Right224: {Right224, Fit720, "TensorFlow", 224, 224, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}},
|
||||
Fit720: {Fit720, "", "Mobile, TV", 720, 720, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit1280: {Fit1280, Fit2048, "Mobile, HD Ready TV", 1280, 1024, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit1920: {Fit1920, Fit2048, "Mobile, Full HD TV", 1920, 1200, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit2048: {Fit2048, "", "Tablets, Cinema 2K", 2048, 2048, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit2560: {Fit2560, "", "Quad HD, Retina Display", 2560, 1600, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit3840: {Fit3840, "", "Ultra HD", 3840, 2400, false, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096
|
||||
Fit4096: {Fit4096, "", "Ultra HD, Retina 4K", 4096, 4096, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit7680: {Fit7680, "", "8K Ultra HD 2, Retina 6K", 7680, 4320, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
}
|
||||
|
||||
// DefaultSizes contains all default size names.
|
||||
var DefaultSizes = []Name{
|
||||
Fit7680,
|
||||
Fit4096,
|
||||
Fit2560,
|
||||
Fit2048,
|
||||
Fit1920,
|
||||
Fit1280,
|
||||
Fit720,
|
||||
Right224,
|
||||
Left224,
|
||||
Colors,
|
||||
Tile500,
|
||||
Tile224,
|
||||
Tile100,
|
||||
Tile50,
|
||||
}
|
||||
|
||||
// Find returns the largest default thumbnail type for the given size limit.
|
||||
func Find(limit int) (name Name, size Size) {
|
||||
for _, name = range DefaultSizes {
|
||||
t := Sizes[name]
|
||||
|
||||
if t.Width <= limit && t.Height <= limit {
|
||||
return name, t
|
||||
}
|
||||
}
|
||||
|
||||
return "", Size{}
|
||||
}
|
||||
|
||||
// Uncached tests if thumbnail type exceeds the cached thumbnails size limit.
|
||||
func (s Size) Uncached() bool {
|
||||
return s.Width > SizePrecached || s.Height > SizePrecached
|
||||
}
|
||||
|
||||
// ExceedsLimit tests if thumbnail type is too large, and can not be rendered at all.
|
||||
func (s Size) ExceedsLimit() bool {
|
||||
return s.Width > MaxSize() || s.Height > MaxSize()
|
||||
Tile50: {Tile50, Tile500, "Lists", 50, 50, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
Tile100: {Tile100, Tile500, "Maps", 100, 100, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
Tile224: {Tile224, Tile500, "TensorFlow, Mosaic", 224, 224, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
Tile500: {Tile500, "", "Tiles", 500, 500, false, false, []ResampleOption{ResampleFillCenter, ResampleDefault}},
|
||||
Colors: {Colors, Fit720, "Color Detection", 3, 3, false, false, []ResampleOption{ResampleResize, ResampleNearestNeighbor, ResamplePng}},
|
||||
Left224: {Left224, Fit720, "TensorFlow", 224, 224, false, false, []ResampleOption{ResampleFillTopLeft, ResampleDefault}},
|
||||
Right224: {Right224, Fit720, "TensorFlow", 224, 224, false, false, []ResampleOption{ResampleFillBottomRight, ResampleDefault}},
|
||||
Fit720: {Fit720, "", "Mobile, TV", 720, 720, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit1280: {Fit1280, Fit2048, "Mobile, HD Ready TV", 1280, 1024, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit1920: {Fit1920, Fit2048, "Mobile, Full HD TV", 1920, 1200, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit2048: {Fit2048, "", "Tablets, Cinema 2K", 2048, 2048, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit2560: {Fit2560, "", "Quad HD, Retina Display", 2560, 1600, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit3840: {Fit3840, "", "Ultra HD", 3840, 2400, true, true, []ResampleOption{ResampleFit, ResampleDefault}}, // Deprecated in favor of fit_4096
|
||||
Fit4096: {Fit4096, "", "Ultra HD, Retina 4K", 4096, 4096, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
Fit7680: {Fit7680, "", "8K Ultra HD 2, Retina 6K", 7680, 4320, true, true, []ResampleOption{ResampleFit, ResampleDefault}},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,19 +54,3 @@ func TestResampleFilter_Imaging(t *testing.T) {
|
|||
assert.Equal(t, float64(1), r.Support)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
t.Run("2048", func(t *testing.T) {
|
||||
name, size := Find(2048)
|
||||
assert.Equal(t, Fit2048, name)
|
||||
assert.Equal(t, 2048, size.Width)
|
||||
assert.Equal(t, 2048, size.Height)
|
||||
})
|
||||
|
||||
t.Run("2000", func(t *testing.T) {
|
||||
name, size := Find(2000)
|
||||
assert.Equal(t, Fit1920, name)
|
||||
assert.Equal(t, 1920, size.Width)
|
||||
assert.Equal(t, 1200, size.Height)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
BIN
internal/thumb/testdata/animated-earth.jpg
vendored
Normal file
BIN
internal/thumb/testdata/animated-earth.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -27,6 +27,9 @@ Additional information can be found in our Developer Guide:
|
|||
package thumb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
|
@ -39,3 +42,38 @@ import (
|
|||
)
|
||||
|
||||
var log = event.Log
|
||||
|
||||
// Url returns a thumbnail url based on hash, thumb name, cdn uri, and preview token.
|
||||
func Url(h, name, contentUri, previewToken string) string {
|
||||
return fmt.Sprintf("%s/t/%s/%s/%s", contentUri, h, previewToken, name)
|
||||
}
|
||||
|
||||
// Thumb represents a photo thumbnail.
|
||||
type Thumb struct {
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Src string `json:"src"`
|
||||
}
|
||||
|
||||
// New creates a new photo thumbnail.
|
||||
func New(w, h int, hash string, s Size, contentUri, previewToken string) Thumb {
|
||||
if s.Width >= w && s.Height >= h {
|
||||
// Smaller
|
||||
return Thumb{W: w, H: h, Src: Url(hash, s.Name.String(), contentUri, previewToken)}
|
||||
}
|
||||
|
||||
srcAspectRatio := float64(w) / float64(h)
|
||||
maxAspectRatio := float64(s.Width) / float64(s.Height)
|
||||
|
||||
var newW, newH int
|
||||
|
||||
if srcAspectRatio > maxAspectRatio {
|
||||
newW = s.Width
|
||||
newH = int(math.Round(float64(newW) / srcAspectRatio))
|
||||
} else {
|
||||
newH = s.Height
|
||||
newW = int(math.Round(float64(newH) * srcAspectRatio))
|
||||
}
|
||||
|
||||
return Thumb{W: newW, H: newH, Src: Url(hash, s.Name.String(), contentUri, previewToken)}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var logBuffer bytes.Buffer
|
||||
|
|
@ -22,3 +24,25 @@ func TestMain(m *testing.M) {
|
|||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
fileHash := "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2"
|
||||
contentUri := "/content"
|
||||
previewToken := "preview-token"
|
||||
|
||||
t.Run("Fit1280", func(t *testing.T) {
|
||||
result := New(1920, 1080, fileHash, Sizes[Fit1280], contentUri, previewToken)
|
||||
|
||||
assert.Equal(t, 1280, result.W)
|
||||
assert.Equal(t, 720, result.H)
|
||||
assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_1280", result.Src)
|
||||
})
|
||||
|
||||
t.Run("Fit3840", func(t *testing.T) {
|
||||
result := New(1920, 1080, fileHash, Sizes[Fit3840], contentUri, previewToken)
|
||||
|
||||
assert.Equal(t, 1920, result.W)
|
||||
assert.Equal(t, 1080, result.H)
|
||||
assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_3840", result.Src)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,32 +2,22 @@ package viewer
|
|||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Thumbs represents photo viewer thumbs in different sizes.
|
||||
type Thumbs struct {
|
||||
Fit720 Thumb `json:"fit_720"`
|
||||
Fit1280 Thumb `json:"fit_1280"`
|
||||
Fit1920 Thumb `json:"fit_1920"`
|
||||
Fit2048 Thumb `json:"fit_2048"`
|
||||
Fit2560 Thumb `json:"fit_2560"`
|
||||
Fit3840 Thumb `json:"fit_3840"`
|
||||
Fit4096 Thumb `json:"fit_4096"`
|
||||
Fit7680 Thumb `json:"fit_7680"`
|
||||
}
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
)
|
||||
|
||||
// Result represents a photo viewer result.
|
||||
type Result struct {
|
||||
UID string `json:"UID"`
|
||||
Title string `json:"Title"`
|
||||
TakenAtLocal time.Time `json:"TakenAtLocal"`
|
||||
Description string `json:"Description"`
|
||||
Favorite bool `json:"Favorite"`
|
||||
Playable bool `json:"Playable"`
|
||||
DownloadUrl string `json:"DownloadUrl"`
|
||||
Width int `json:"Width"`
|
||||
Height int `json:"Height"`
|
||||
Thumbs Thumbs `json:"Thumbs"`
|
||||
UID string `json:"UID"`
|
||||
Title string `json:"Title"`
|
||||
TakenAtLocal time.Time `json:"TakenAtLocal"`
|
||||
Description string `json:"Description"`
|
||||
Favorite bool `json:"Favorite"`
|
||||
Playable bool `json:"Playable"`
|
||||
DownloadUrl string `json:"DownloadUrl"`
|
||||
Width int `json:"Width"`
|
||||
Height int `json:"Height"`
|
||||
Thumbs thumb.Public `json:"Thumbs"`
|
||||
}
|
||||
|
||||
// Results represents a list of viewer search results.
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
package viewer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
)
|
||||
|
||||
// DownloadUrl returns a download url based on hash, api uri, and download token.
|
||||
func DownloadUrl(h, apiUri, downloadToken string) string {
|
||||
return fmt.Sprintf("%s/dl/%s?t=%s", apiUri, h, downloadToken)
|
||||
}
|
||||
|
||||
// ThumbUrl returns a thumbnail url based on hash, thumb name, cdn uri, and preview token.
|
||||
func ThumbUrl(h, name, contentUri, previewToken string) string {
|
||||
return fmt.Sprintf("%s/t/%s/%s/%s", contentUri, h, previewToken, name)
|
||||
}
|
||||
|
||||
// Thumb represents a photo viewer thumbnail.
|
||||
type Thumb struct {
|
||||
W int `json:"w"`
|
||||
H int `json:"h"`
|
||||
Src string `json:"src"`
|
||||
}
|
||||
|
||||
// NewThumb creates a new photo viewer thumb.
|
||||
func NewThumb(w, h int, hash string, s thumb.Size, contentUri, previewToken string) Thumb {
|
||||
if s.Width >= w && s.Height >= h {
|
||||
// Smaller
|
||||
return Thumb{W: w, H: h, Src: ThumbUrl(hash, s.Name.String(), contentUri, previewToken)}
|
||||
}
|
||||
|
||||
srcAspectRatio := float64(w) / float64(h)
|
||||
maxAspectRatio := float64(s.Width) / float64(s.Height)
|
||||
|
||||
var newW, newH int
|
||||
|
||||
if srcAspectRatio > maxAspectRatio {
|
||||
newW = s.Width
|
||||
newH = int(math.Round(float64(newW) / srcAspectRatio))
|
||||
} else {
|
||||
newH = s.Height
|
||||
newW = int(math.Round(float64(newH) * srcAspectRatio))
|
||||
}
|
||||
|
||||
return Thumb{W: newW, H: newH, Src: ThumbUrl(hash, s.Name.String(), contentUri, previewToken)}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package viewer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/thumb"
|
||||
)
|
||||
|
||||
func TestNewThumb(t *testing.T) {
|
||||
fileHash := "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2"
|
||||
contentUri := "/content"
|
||||
previewToken := "preview-token"
|
||||
|
||||
t.Run("Fit1280", func(t *testing.T) {
|
||||
result := NewThumb(1920, 1080, fileHash, thumb.Sizes[thumb.Fit1280], contentUri, previewToken)
|
||||
|
||||
assert.Equal(t, 1280, result.W)
|
||||
assert.Equal(t, 720, result.H)
|
||||
assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_1280", result.Src)
|
||||
})
|
||||
|
||||
t.Run("Fit3840", func(t *testing.T) {
|
||||
result := NewThumb(1920, 1080, fileHash, thumb.Sizes[thumb.Fit3840], contentUri, previewToken)
|
||||
|
||||
assert.Equal(t, 1920, result.W)
|
||||
assert.Equal(t, 1080, result.H)
|
||||
assert.Equal(t, "/content/t/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2/preview-token/fit_3840", result.Src)
|
||||
})
|
||||
}
|
||||
10
internal/viewer/url.go
Normal file
10
internal/viewer/url.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package viewer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DownloadUrl returns a download url based on hash, api uri, and download token.
|
||||
func DownloadUrl(h, apiUri, downloadToken string) string {
|
||||
return fmt.Sprintf("%s/dl/%s?t=%s", apiUri, h, downloadToken)
|
||||
}
|
||||
23
internal/viewer/url_test.go
Normal file
23
internal/viewer/url_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package viewer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDownloadUrl(t *testing.T) {
|
||||
apiUri := "/api/v1"
|
||||
|
||||
t.Run("WithToken", func(t *testing.T) {
|
||||
dlToken := "3tcsggxy"
|
||||
result := DownloadUrl("d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2", apiUri, dlToken)
|
||||
assert.Equal(t, "/api/v1/dl/d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2?t=3tcsggxy", result)
|
||||
})
|
||||
|
||||
t.Run("NoToken", func(t *testing.T) {
|
||||
dlToken := ""
|
||||
result := DownloadUrl("653cd9e5754e98d899e9ba30c9075da4ebb16141", apiUri, dlToken)
|
||||
assert.Equal(t, "/api/v1/dl/653cd9e5754e98d899e9ba30c9075da4ebb16141?t=", result)
|
||||
})
|
||||
}
|
||||
|
|
@ -8,15 +8,15 @@ import (
|
|||
|
||||
// CaseInsensitive tests if a storage path is case-insensitive.
|
||||
func CaseInsensitive(storagePath string) (result bool, err error) {
|
||||
tmpName := filepath.Join(storagePath, "caseTest.tmp")
|
||||
tmpName := filepath.Join(storagePath, ".caseTest.tmp")
|
||||
|
||||
if err := os.WriteFile(tmpName, []byte("{}"), 0666); err != nil {
|
||||
if err = os.WriteFile(tmpName, []byte("{}"), os.ModePerm); err != nil {
|
||||
return false, fmt.Errorf("%s not writable", filepath.Base(storagePath))
|
||||
}
|
||||
|
||||
defer os.Remove(tmpName)
|
||||
|
||||
result = FileExists(filepath.Join(storagePath, "CASETEST.TMP"))
|
||||
result = FileExists(filepath.Join(storagePath, ".CASETEST.TMP"))
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,13 @@ func Dirs(root string, recursive bool, followLinks bool) (result []string, err e
|
|||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Skip if symlink does not point to existing directory.
|
||||
if typ == os.ModeSymlink {
|
||||
if info, err := os.Stat(fileName); err != nil || !info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
|
||||
if fileName != root {
|
||||
if !recursive {
|
||||
appendResult(fileName)
|
||||
|
|
@ -150,7 +157,7 @@ func Dirs(root string, recursive bool, followLinks bool) (result []string, err e
|
|||
appendResult(fileName)
|
||||
|
||||
return nil
|
||||
} else if resolved, err := filepath.EvalSymlinks(fileName); err == nil {
|
||||
} else if resolved, err := Resolve(fileName); err == nil {
|
||||
symlinksMutex.Lock()
|
||||
defer symlinksMutex.Unlock()
|
||||
|
||||
|
|
|
|||
21
pkg/fs/resolve.go
Normal file
21
pkg/fs/resolve.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Resolve returns the absolute file path, with all symlinks resolved.
|
||||
func Resolve(filePath string) (string, error) {
|
||||
if filePath == "" {
|
||||
return "", errors.New("no such file or directory")
|
||||
}
|
||||
|
||||
if target, err := filepath.EvalSymlinks(filePath); err != nil {
|
||||
return "", errors.New("no such file or directory")
|
||||
} else if target, err = filepath.Abs(target); target != "" {
|
||||
return target, err
|
||||
} else {
|
||||
return filepath.Abs(filePath)
|
||||
}
|
||||
}
|
||||
41
pkg/fs/resolve_test.go
Normal file
41
pkg/fs/resolve_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolve(t *testing.T) {
|
||||
tmpDir := os.TempDir()
|
||||
|
||||
linkName := filepath.Join(tmpDir, uuid.NewString()+"-link.tmp")
|
||||
targetName := filepath.Join(tmpDir, uuid.NewString()+".tmp")
|
||||
|
||||
// Delete files after test.
|
||||
defer func(link, target string) {
|
||||
_ = os.Remove(link)
|
||||
_ = os.Remove(target)
|
||||
}(linkName, targetName)
|
||||
|
||||
// Create empty test target file.
|
||||
if targetFile, err := os.OpenFile(targetName, os.O_RDONLY|os.O_CREATE, os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if err = targetFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.Symlink(targetName, linkName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if result, err := Resolve(linkName); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
assert.Equal(t, targetName, result)
|
||||
}
|
||||
}
|
||||
39
pkg/fs/symlink.go
Normal file
39
pkg/fs/symlink.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SymlinksSupported tests if a storage path supports symlinks.
|
||||
func SymlinksSupported(storagePath string) (bool, error) {
|
||||
linkName := filepath.Join(storagePath, uuid.NewString()+"-link.tmp")
|
||||
targetName := filepath.Join(storagePath, uuid.NewString()+".tmp")
|
||||
|
||||
// Delete files after test.
|
||||
defer func(link, target string) {
|
||||
_ = os.Remove(link)
|
||||
_ = os.Remove(target)
|
||||
}(linkName, targetName)
|
||||
|
||||
// Create empty test target file.
|
||||
if targetFile, err := os.OpenFile(targetName, os.O_RDONLY|os.O_CREATE, os.ModePerm); err != nil {
|
||||
return false, err
|
||||
} else if err = targetFile.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Create test link.
|
||||
if err := os.Symlink(filepath.Base(targetName), linkName); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Resolve and compare test target.
|
||||
if linkTarget, err := Resolve(linkName); err != nil {
|
||||
return false, err
|
||||
} else {
|
||||
return linkTarget == targetName, nil
|
||||
}
|
||||
}
|
||||
16
pkg/fs/symlink_test.go
Normal file
16
pkg/fs/symlink_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSymlinksSupported(t *testing.T) {
|
||||
t.Run("TempDir", func(t *testing.T) {
|
||||
ok, err := SymlinksSupported(os.TempDir())
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
})
|
||||
}
|
||||
|
|
@ -11,11 +11,10 @@ func SkipWalk(fileName string, isDir, isSymlink bool, done Done, ignore *IgnoreL
|
|||
isIgnored := ignore.Ignore(fileName)
|
||||
|
||||
if isSymlink {
|
||||
// Symlinks are skipped by default unless they are links to directories
|
||||
skip = true
|
||||
|
||||
// Symlink points to directory?
|
||||
if link, err := os.Stat(fileName); err == nil && link.IsDir() {
|
||||
// Skip directories.
|
||||
skip = true
|
||||
resolved, err := filepath.EvalSymlinks(fileName)
|
||||
|
||||
if err != nil || isIgnored || isDone || done[resolved].Exists() {
|
||||
|
|
@ -24,7 +23,9 @@ func SkipWalk(fileName string, isDir, isSymlink bool, done Done, ignore *IgnoreL
|
|||
// Don't traverse symlinks if they are hidden or already done...
|
||||
done[resolved] = Found
|
||||
}
|
||||
} else {
|
||||
} else if err != nil {
|
||||
// Also skip on error.
|
||||
skip = true
|
||||
result = filepath.SkipDir
|
||||
}
|
||||
} else if isDir {
|
||||
|
|
|
|||
2
pro
2
pro
|
|
@ -1 +1 @@
|
|||
Subproject commit b5c6911123794dd8549980f705e9e9e0590bccab
|
||||
Subproject commit 16df622ddd594f38433fa294a94a2651065484fe
|
||||
Loading…
Add table
Add a link
Reference in a new issue