FS: Improve matching of related media files #2983

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-10-20 16:02:52 +02:00
parent a912383ede
commit 54f281a425
8 changed files with 500 additions and 368 deletions

View file

@ -305,19 +305,6 @@ func (m *MediaFile) Checksum() string {
return m.checksum return m.checksum
} }
// EditedName returns the corresponding edited image file name as used by Apple (e.g. IMG_E12345.JPG).
func (m *MediaFile) EditedName() string {
basename := filepath.Base(m.fileName)
if strings.ToUpper(basename[:4]) == "IMG_" && strings.ToUpper(basename[:5]) != "IMG_E" {
if filename := filepath.Dir(m.fileName) + string(os.PathSeparator) + basename[:4] + "E" + basename[4:]; fs.FileExists(filename) {
return filename
}
}
return ""
}
// PathNameInfo returns file name infos for indexing. // PathNameInfo returns file name infos for indexing.
func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relativePath, relativeName string) { func (m *MediaFile) PathNameInfo(stripSequence bool) (fileRoot, fileBase, relativePath, relativeName string) {
fileRoot = m.Root() fileRoot = m.Root()
@ -444,11 +431,29 @@ func (m *MediaFile) SubDir(dir string) string {
return filepath.Join(filepath.Dir(m.fileName), dir) return filepath.Join(filepath.Dir(m.fileName), dir)
} }
// AbsPrefix returns the directory and base filename without any extensions.
func (m *MediaFile) AbsPrefix(stripSequence bool) string {
return fs.AbsPrefix(m.FileName(), stripSequence)
}
// BasePrefix returns the filename base without any extensions and path. // BasePrefix returns the filename base without any extensions and path.
func (m *MediaFile) BasePrefix(stripSequence bool) string { func (m *MediaFile) BasePrefix(stripSequence bool) string {
return fs.BasePrefix(m.FileName(), stripSequence) return fs.BasePrefix(m.FileName(), stripSequence)
} }
// EditedName returns the corresponding edited image file name as used by Apple (e.g. IMG_E12345.JPG).
func (m *MediaFile) EditedName() string {
basename := filepath.Base(m.fileName)
if strings.ToUpper(basename[:4]) == "IMG_" && strings.ToUpper(basename[:5]) != "IMG_E" {
if filename := filepath.Dir(m.fileName) + string(os.PathSeparator) + basename[:4] + "E" + basename[4:]; fs.FileExists(filename) {
return filename
}
}
return ""
}
// Root returns the file root directory. // Root returns the file root directory.
func (m *MediaFile) Root() string { func (m *MediaFile) Root() string {
if m.fileRoot != entity.RootUnknown { if m.fileRoot != entity.RootUnknown {
@ -484,11 +489,6 @@ func (m *MediaFile) Root() string {
return m.fileRoot return m.fileRoot
} }
// AbsPrefix returns the directory and base filename without any extensions.
func (m *MediaFile) AbsPrefix(stripSequence bool) string {
return fs.AbsPrefix(m.FileName(), stripSequence)
}
// MimeType returns the mime type. // MimeType returns the mime type.
func (m *MediaFile) MimeType() string { func (m *MediaFile) MimeType() string {
if m.mimeType != "" { if m.mimeType != "" {

View file

@ -11,10 +11,16 @@ import (
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
) )
// RelatedFilePathPrefix returns the absolute file path and name prefix without file extensions
// and suffixes to be ignored.
func (m *MediaFile) RelatedFilePathPrefix(stripSequence bool) (s string) {
return fs.RelatedFilePathPrefix(m.FileName(), stripSequence)
}
// RelatedFiles returns files which are related to this file. // RelatedFiles returns files which are related to this file.
func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err error) { func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err error) {
// File path and name without any extensions. // Related file path prefix without ignored file name extensions and suffixes.
prefix := m.AbsPrefix(stripSequence) filePathPrefix := m.RelatedFilePathPrefix(stripSequence)
// Storage folder path prefixes. // Storage folder path prefixes.
sidecarPrefix := Config().SidecarPath() + "/" sidecarPrefix := Config().SidecarPath() + "/"
@ -30,34 +36,36 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
skipVectors := Config().DisableVectors() skipVectors := Config().DisableVectors()
// Replace sidecar with originals path in search prefix. // Replace sidecar with originals path in search prefix.
if len(sidecarPrefix) > 1 && sidecarPrefix != originalsPrefix && strings.HasPrefix(prefix, sidecarPrefix) { if len(sidecarPrefix) > 1 && sidecarPrefix != originalsPrefix && strings.HasPrefix(filePathPrefix, sidecarPrefix) {
prefix = strings.Replace(prefix, sidecarPrefix, originalsPrefix, 1) filePathPrefix = strings.Replace(filePathPrefix, sidecarPrefix, originalsPrefix, 1)
log.Debugf("media: replaced sidecar with originals path in related file matching pattern") log.Debugf("media: replaced sidecar with originals path in related file matching pattern")
} }
// Quote path for glob. // globPattern specifies the escaped naming pattern to find related files.
var globPattern string
// Strip common name sequences like "copy 2" or "(3)"?
if stripSequence { if stripSequence {
// Strip common name sequences like "copy 2" and escape meta characters. globPattern = regexp.QuoteMeta(filePathPrefix) + "*"
prefix = regexp.QuoteMeta(prefix)
} else { } else {
// Use strict file name matching and escape meta characters. globPattern = regexp.QuoteMeta(filePathPrefix+".") + "*"
prefix = regexp.QuoteMeta(prefix + ".")
} }
// Find related files. // Find files that match the pattern.
matches, err := filepath.Glob(prefix + "*") matches, err := filepath.Glob(globPattern)
if err != nil { if err != nil {
return result, err return result, err
} }
// Search for related edited image file name (as used by Apple) and add it to the list of files, if found. // Additionally include edited version in the file matches, if exists.
if name := m.EditedName(); name != "" { if name := m.EditedName(); name != "" {
matches = append(matches, name) matches = append(matches, name)
} }
isHEIC := false isHEIC := false
// Process files that matched the pattern.
for _, fileName := range matches { for _, fileName := range matches {
f, fileErr := NewMediaFile(fileName) f, fileErr := NewMediaFile(fileName)
@ -65,7 +73,7 @@ func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err e
continue continue
} }
// Ignore file format? // Skip file if its format must be ignored based on the configuration.
switch { switch {
case skipRaw && f.IsRaw(): case skipRaw && f.IsRaw():
log.Debugf("media: skipped related raw image %s", clean.Log(f.RootRelName())) log.Debugf("media: skipped related raw image %s", clean.Log(f.RootRelName()))

View file

@ -0,0 +1,256 @@
package photoprism
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
)
func TestMediaFile_RelatedFilePathPrefix(t *testing.T) {
t.Run("IMG_1234_HEVC.JPEG", func(t *testing.T) {
fileName := "testdata/related/IMG_1234_HEVC (3).JPEG"
f, err := NewMediaFile(fileName)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, fileName, f.FileName())
assert.Equal(t, "testdata/related/IMG_1234_HEVC", f.AbsPrefix(true))
assert.Equal(t, "testdata/related/IMG_1234_HEVC (3)", f.AbsPrefix(false))
assert.Equal(t, "testdata/related/IMG_1234", f.RelatedFilePathPrefix(true))
assert.Equal(t, "testdata/related/IMG_1234_HEVC (3)", f.RelatedFilePathPrefix(false))
})
t.Run("fern_green.jpg", func(t *testing.T) {
f, err := NewMediaFile(conf.ExamplesPath() + "/fern_green.jpg")
if err != nil {
t.Fatal(err)
}
expected := conf.ExamplesPath() + "/fern_green"
assert.Equal(t, expected, f.RelatedFilePathPrefix(true))
assert.Equal(t, expected, f.RelatedFilePathPrefix(false))
})
}
func TestMediaFile_RelatedFiles(t *testing.T) {
c := config.TestConfig()
t.Run("example.tif", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/example.tif")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
assert.Len(t, related.Files, 6)
assert.True(t, related.HasPreview())
for _, result := range related.Files {
t.Logf("FileName: %s", result.FileName())
filename := result.FileName()
if len(filename) < 2 {
t.Fatalf("filename not be longer: %s", filename)
}
extension := result.Extension()
if len(extension) < 2 {
t.Fatalf("extension should be longer: %s", extension)
}
relativePath := result.RelPath(c.ExamplesPath())
if len(relativePath) > 0 {
t.Fatalf("relative path should be empty: %s", relativePath)
}
}
})
t.Run("canon_eos_6d.dng", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/canon_eos_6d.dng")
if err != nil {
t.Fatal(err)
}
expectedBaseFilename := c.ExamplesPath() + "/canon_eos_6d"
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
assert.Len(t, related.Files, 3)
assert.False(t, related.HasPreview())
for _, result := range related.Files {
t.Logf("FileName: %s", result.FileName())
filename := result.FileName()
extension := result.Extension()
baseFilename := filename[0 : len(filename)-len(extension)]
assert.Equal(t, expectedBaseFilename, baseFilename)
}
})
t.Run("iphone_7.heic", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
}
expectedBaseFilename := c.ExamplesPath() + "/iphone_7"
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
assert.GreaterOrEqual(t, len(related.Files), 3)
for _, result := range related.Files {
t.Logf("FileName: %s", result.FileName())
filename := result.FileName()
extension := result.Extension()
baseFilename := filename[0 : len(filename)-len(extension)]
if result.IsJpeg() {
assert.Contains(t, expectedBaseFilename, "examples/iphone_7")
} else {
assert.Equal(t, expectedBaseFilename, baseFilename)
}
}
})
t.Run("2015-02-04.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile("testdata/2015-02-04.jpg")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
if related.Main == nil {
t.Fatal("main file must not be nil")
}
if len(related.Files) != 4 {
t.Fatalf("length is %d, should be 4", len(related.Files))
}
t.Logf("FILE: %s, %s", related.Main.FileType(), related.Main.MimeType())
assert.Equal(t, "2015-02-04.jpg", related.Main.BaseName())
assert.Equal(t, "2015-02-04.jpg", related.Files[0].BaseName())
assert.Equal(t, "2015-02-04(1).jpg", related.Files[1].BaseName())
assert.Equal(t, "2015-02-04.jpg.json", related.Files[2].BaseName())
assert.Equal(t, "2015-02-04.jpg(1).json", related.Files[3].BaseName())
})
t.Run("2015-02-04(1).jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile("testdata/2015-02-04(1).jpg")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(false)
if err != nil {
t.Fatal(err)
}
if related.Main == nil {
t.Fatal("main file must not be nil")
}
if len(related.Files) != 1 {
t.Fatalf("length is %d, should be 1", len(related.Files))
}
assert.Equal(t, "2015-02-04(1).jpg", related.Main.BaseName())
assert.Equal(t, "2015-02-04(1).jpg", related.Files[0].BaseName())
})
t.Run("2015-02-04(1).jpg stacked", func(t *testing.T) {
mediaFile, err := NewMediaFile("testdata/2015-02-04(1).jpg")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
if related.Main == nil {
t.Fatal("main file must not be nil")
}
if len(related.Files) != 4 {
t.Fatalf("length is %d, should be 4", len(related.Files))
}
assert.Equal(t, "2015-02-04.jpg", related.Main.BaseName())
assert.Equal(t, "2015-02-04.jpg", related.Files[0].BaseName())
assert.Equal(t, "2015-02-04(1).jpg", related.Files[1].BaseName())
assert.Equal(t, "2015-02-04.jpg.json", related.Files[2].BaseName())
assert.Equal(t, "2015-02-04.jpg(1).json", related.Files[3].BaseName())
})
}
func TestMediaFile_RelatedFiles_Ordering(t *testing.T) {
c := config.TestConfig()
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120.JPG")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
assert.Len(t, related.Files, 5)
assert.Equal(t, c.ExamplesPath()+"/IMG_4120.AAE", related.Files[0].FileName())
assert.Equal(t, c.ExamplesPath()+"/IMG_4120.JPG", related.Files[1].FileName())
for _, result := range related.Files {
filename := result.FileName()
t.Logf("FileName: %s", filename)
}
}

View file

@ -293,19 +293,17 @@ func TestMediaFile_LensMake(t *testing.T) {
} }
func TestMediaFile_FocalLength(t *testing.T) { func TestMediaFile_FocalLength(t *testing.T) {
t.Run("/cat_brown.jpg", func(t *testing.T) { c := config.TestConfig()
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/cat_brown.jpg") t.Run("/cat_brown.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, 29, mediaFile.FocalLength()) assert.Equal(t, 29, mediaFile.FocalLength())
}) })
t.Run("/elephants.jpg", func(t *testing.T) { t.Run("/elephants.jpg", func(t *testing.T) {
conf := config.TestConfig() mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -314,19 +312,17 @@ func TestMediaFile_FocalLength(t *testing.T) {
} }
func TestMediaFile_FNumber(t *testing.T) { func TestMediaFile_FNumber(t *testing.T) {
t.Run("/cat_brown.jpg", func(t *testing.T) { c := config.TestConfig()
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/cat_brown.jpg") t.Run("/cat_brown.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, float32(2.2), mediaFile.FNumber()) assert.Equal(t, float32(2.2), mediaFile.FNumber())
}) })
t.Run("/elephants.jpg", func(t *testing.T) { t.Run("/elephants.jpg", func(t *testing.T) {
conf := config.TestConfig() mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -335,19 +331,17 @@ func TestMediaFile_FNumber(t *testing.T) {
} }
func TestMediaFile_Iso(t *testing.T) { func TestMediaFile_Iso(t *testing.T) {
t.Run("/cat_brown.jpg", func(t *testing.T) { c := config.TestConfig()
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/cat_brown.jpg") t.Run("/cat_brown.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, 32, mediaFile.Iso()) assert.Equal(t, 32, mediaFile.Iso())
}) })
t.Run("/elephants.jpg", func(t *testing.T) { t.Run("/elephants.jpg", func(t *testing.T) {
conf := config.TestConfig() mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -356,19 +350,18 @@ func TestMediaFile_Iso(t *testing.T) {
} }
func TestMediaFile_Exposure(t *testing.T) { func TestMediaFile_Exposure(t *testing.T) {
t.Run("/cat_brown.jpg", func(t *testing.T) { c := config.TestConfig()
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/cat_brown.jpg") t.Run("/cat_brown.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, "1/50", mediaFile.Exposure()) assert.Equal(t, "1/50", mediaFile.Exposure())
}) })
t.Run("/elephants.jpg", func(t *testing.T) { t.Run("/elephants.jpg", func(t *testing.T) {
conf := config.TestConfig() mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -377,9 +370,9 @@ func TestMediaFile_Exposure(t *testing.T) {
} }
func TestMediaFileCanonicalName(t *testing.T) { func TestMediaFileCanonicalName(t *testing.T) {
conf := config.TestConfig() c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg") mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -408,28 +401,27 @@ func TestMediaFileCanonicalNameFromFile(t *testing.T) {
} }
func TestMediaFile_CanonicalNameFromFileWithDirectory(t *testing.T) { func TestMediaFile_CanonicalNameFromFileWithDirectory(t *testing.T) {
conf := config.TestConfig() c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg") mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, conf.ExamplesPath()+"/beach_wood", mediaFile.CanonicalNameFromFileWithDirectory()) assert.Equal(t, c.ExamplesPath()+"/beach_wood", mediaFile.CanonicalNameFromFileWithDirectory())
} }
func TestMediaFile_EditedFilename(t *testing.T) { func TestMediaFile_EditedFilename(t *testing.T) {
conf := config.TestConfig() c := config.TestConfig()
t.Run("IMG_4120.JPG", func(t *testing.T) { t.Run("IMG_4120.JPG", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/IMG_4120.JPG") mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120.JPG")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, conf.ExamplesPath()+"/IMG_E4120.JPG", mediaFile.EditedName()) assert.Equal(t, c.ExamplesPath()+"/IMG_E4120.JPG", mediaFile.EditedName())
}) })
t.Run("fern_green.jpg", func(t *testing.T) { t.Run("fern_green.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/fern_green.jpg") mediaFile, err := NewMediaFile(c.ExamplesPath() + "/fern_green.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -437,228 +429,10 @@ func TestMediaFile_EditedFilename(t *testing.T) {
}) })
} }
func TestMediaFile_RelatedFiles(t *testing.T) {
conf := config.TestConfig()
t.Run("example.tif", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/example.tif")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
assert.Len(t, related.Files, 6)
assert.True(t, related.HasPreview())
for _, result := range related.Files {
t.Logf("FileName: %s", result.FileName())
filename := result.FileName()
if len(filename) < 2 {
t.Fatalf("filename not be longer: %s", filename)
}
extension := result.Extension()
if len(extension) < 2 {
t.Fatalf("extension should be longer: %s", extension)
}
relativePath := result.RelPath(conf.ExamplesPath())
if len(relativePath) > 0 {
t.Fatalf("relative path should be empty: %s", relativePath)
}
}
})
t.Run("canon_eos_6d.dng", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/canon_eos_6d.dng")
if err != nil {
t.Fatal(err)
}
expectedBaseFilename := conf.ExamplesPath() + "/canon_eos_6d"
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
assert.Len(t, related.Files, 3)
assert.False(t, related.HasPreview())
for _, result := range related.Files {
t.Logf("FileName: %s", result.FileName())
filename := result.FileName()
extension := result.Extension()
baseFilename := filename[0 : len(filename)-len(extension)]
assert.Equal(t, expectedBaseFilename, baseFilename)
}
})
t.Run("iphone_7.heic", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/iphone_7.heic")
if err != nil {
t.Fatal(err)
}
expectedBaseFilename := conf.ExamplesPath() + "/iphone_7"
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
assert.GreaterOrEqual(t, len(related.Files), 3)
for _, result := range related.Files {
t.Logf("FileName: %s", result.FileName())
filename := result.FileName()
extension := result.Extension()
baseFilename := filename[0 : len(filename)-len(extension)]
if result.IsJpeg() {
assert.Contains(t, expectedBaseFilename, "examples/iphone_7")
} else {
assert.Equal(t, expectedBaseFilename, baseFilename)
}
}
})
t.Run("2015-02-04.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile("testdata/2015-02-04.jpg")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
if related.Main == nil {
t.Fatal("main file must not be nil")
}
if len(related.Files) != 4 {
t.Fatalf("length is %d, should be 4", len(related.Files))
}
t.Logf("FILE: %s, %s", related.Main.FileType(), related.Main.MimeType())
assert.Equal(t, "2015-02-04.jpg", related.Main.BaseName())
assert.Equal(t, "2015-02-04.jpg", related.Files[0].BaseName())
assert.Equal(t, "2015-02-04(1).jpg", related.Files[1].BaseName())
assert.Equal(t, "2015-02-04.jpg.json", related.Files[2].BaseName())
assert.Equal(t, "2015-02-04.jpg(1).json", related.Files[3].BaseName())
})
t.Run("2015-02-04(1).jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile("testdata/2015-02-04(1).jpg")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(false)
if err != nil {
t.Fatal(err)
}
if related.Main == nil {
t.Fatal("main file must not be nil")
}
if len(related.Files) != 1 {
t.Fatalf("length is %d, should be 1", len(related.Files))
}
assert.Equal(t, "2015-02-04(1).jpg", related.Main.BaseName())
assert.Equal(t, "2015-02-04(1).jpg", related.Files[0].BaseName())
})
t.Run("2015-02-04(1).jpg stacked", func(t *testing.T) {
mediaFile, err := NewMediaFile("testdata/2015-02-04(1).jpg")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
if related.Main == nil {
t.Fatal("main file must not be nil")
}
if len(related.Files) != 4 {
t.Fatalf("length is %d, should be 4", len(related.Files))
}
assert.Equal(t, "2015-02-04.jpg", related.Main.BaseName())
assert.Equal(t, "2015-02-04.jpg", related.Files[0].BaseName())
assert.Equal(t, "2015-02-04(1).jpg", related.Files[1].BaseName())
assert.Equal(t, "2015-02-04.jpg.json", related.Files[2].BaseName())
assert.Equal(t, "2015-02-04.jpg(1).json", related.Files[3].BaseName())
})
}
func TestMediaFile_RelatedFiles_Ordering(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/IMG_4120.JPG")
if err != nil {
t.Fatal(err)
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
assert.Len(t, related.Files, 5)
assert.Equal(t, conf.ExamplesPath()+"/IMG_4120.AAE", related.Files[0].FileName())
assert.Equal(t, conf.ExamplesPath()+"/IMG_4120.JPG", related.Files[1].FileName())
for _, result := range related.Files {
filename := result.FileName()
t.Logf("FileName: %s", filename)
}
}
func TestMediaFile_SetFilename(t *testing.T) { func TestMediaFile_SetFilename(t *testing.T) {
conf := config.TestConfig() c := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/turtle_brown_blue.jpg") mediaFile, err := NewMediaFile(c.ExamplesPath() + "/turtle_brown_blue.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -824,29 +598,57 @@ func TestMediaFile_Directory(t *testing.T) {
}) })
} }
func TestMediaFile_Basename(t *testing.T) { func TestMediaFile_AbsPrefix(t *testing.T) {
t.Run("/limes.jpg", func(t *testing.T) { c := config.TestConfig()
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/limes.jpg") t.Run("/limes.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/limes.jpg")
if err != nil {
t.Fatal(err)
}
expected := c.ExamplesPath() + "/limes"
assert.Equal(t, expected, mediaFile.AbsPrefix(true))
})
t.Run("/IMG_4120 copy.JPG", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120 copy.JPG")
if err != nil {
t.Fatal(err)
}
expected := c.ExamplesPath() + "/IMG_4120"
assert.Equal(t, expected, mediaFile.AbsPrefix(true))
})
t.Run("/IMG_4120 (1).JPG", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120 (1).JPG")
if err != nil {
t.Fatal(err)
}
expected := c.ExamplesPath() + "/IMG_4120"
assert.Equal(t, expected, mediaFile.AbsPrefix(true))
})
}
func TestMediaFile_BasePrefix(t *testing.T) {
c := config.TestConfig()
t.Run("/limes.jpg", func(t *testing.T) {
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/limes.jpg")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, "limes", mediaFile.BasePrefix(true)) assert.Equal(t, "limes", mediaFile.BasePrefix(true))
}) })
t.Run("/IMG_4120 copy.JPG", func(t *testing.T) { t.Run("/IMG_4120 copy.JPG", func(t *testing.T) {
conf := config.TestConfig() mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120 copy.JPG")
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/IMG_4120 copy.JPG")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
assert.Equal(t, "IMG_4120", mediaFile.BasePrefix(true)) assert.Equal(t, "IMG_4120", mediaFile.BasePrefix(true))
}) })
t.Run("/IMG_4120 (1).JPG", func(t *testing.T) { t.Run("/IMG_4120 (1).JPG", func(t *testing.T) {
conf := config.TestConfig() mediaFile, err := NewMediaFile(c.ExamplesPath() + "/IMG_4120 (1).JPG")
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/IMG_4120 (1).JPG")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -1,55 +0,0 @@
package fs
import (
"path/filepath"
"strconv"
"strings"
)
// StripSequence removes common sequence patterns at the end of file names.
func StripSequence(name string) string {
// Strip numeric extensions like .00000, .00001, .4542353245,.... (at least 5 digits).
if dot := strings.LastIndex(name, "."); dot != -1 && len(name[dot+1:]) >= 5 {
if i, err := strconv.Atoi(name[dot+1:]); err == nil && i >= 0 {
name = name[:dot]
}
}
// Other common sequential naming schemes.
if end := strings.Index(name, "("); end != -1 {
// Copies created by Chrome & Windows, example: IMG_1234 (2).
name = name[:end]
} else if end := strings.Index(name, " copy"); end != -1 {
// Copies created by OS X, example: IMG_1234 copy 2.
name = name[:end]
}
name = strings.TrimSpace(name)
return name
}
// BasePrefix returns the filename base without any extensions and path.
func BasePrefix(fileName string, stripSequence bool) string {
name := StripKnownExt(StripExt(filepath.Base(fileName)))
if !stripSequence {
return name
}
return StripSequence(name)
}
// RelPrefix returns the relative filename.
func RelPrefix(fileName, dir string, stripSequence bool) string {
if name := RelName(fileName, dir); name != "" {
return AbsPrefix(name, stripSequence)
}
return BasePrefix(fileName, stripSequence)
}
// AbsPrefix returns the directory and base filename without any extensions.
func AbsPrefix(fileName string, stripSequence bool) string {
return filepath.Join(filepath.Dir(fileName), BasePrefix(fileName, stripSequence))
}

78
pkg/fs/filepath.go Normal file
View file

@ -0,0 +1,78 @@
package fs
import (
"path/filepath"
"regexp"
"strconv"
"strings"
)
// RelatedMediaFileSuffix is a regular expression that matches suffixes of related media files,
// see https://github.com/photoprism/photoprism/issues/2983 (Support Live Photos downloaded with "iCloudPD").
var RelatedMediaFileSuffix = regexp.MustCompile(`(?i)_(jpg|jpeg|hevc)$`)
// StripSequence removes common sequence patterns at the end of file names.
func StripSequence(fileName string) string {
if fileName == "" {
return ""
}
// Strip numeric extensions like .00000, .00001, .4542353245,.... (at least 5 digits).
if dot := strings.LastIndex(fileName, "."); dot != -1 && len(fileName[dot+1:]) >= 5 {
if i, err := strconv.Atoi(fileName[dot+1:]); err == nil && i >= 0 {
fileName = fileName[:dot]
}
}
// Other common sequential naming schemes.
if end := strings.Index(fileName, "("); end != -1 {
// Copies created by Chrome & Windows, example: IMG_1234 (2).
fileName = fileName[:end]
} else if end := strings.Index(fileName, " copy"); end != -1 {
// Copies created by OS X, example: IMG_1234 copy 2.
fileName = fileName[:end]
}
fileName = strings.TrimSpace(fileName)
return fileName
}
// BasePrefix returns the filename base without any extensions and path.
func BasePrefix(fileName string, stripSequence bool) string {
fileBase := StripKnownExt(StripExt(filepath.Base(fileName)))
if !stripSequence {
return fileBase
}
return StripSequence(fileBase)
}
// RelPrefix returns the relative filename.
func RelPrefix(fileName, dir string, stripSequence bool) string {
if name := RelName(fileName, dir); name != "" {
return AbsPrefix(name, stripSequence)
}
return BasePrefix(fileName, stripSequence)
}
// AbsPrefix returns the directory and base filename without any extensions.
func AbsPrefix(fileName string, stripSequence bool) string {
if fileName == "" {
return ""
}
return filepath.Join(filepath.Dir(fileName), BasePrefix(fileName, stripSequence))
}
// RelatedFilePathPrefix returns the absolute file path and name prefix without file extensions and media file
// suffixes to be ignored for comparison, see https://github.com/photoprism/photoprism/issues/2983.
func RelatedFilePathPrefix(fileName string, stripSequence bool) string {
if fileName == "" {
return ""
}
return RelatedMediaFileSuffix.ReplaceAllString(AbsPrefix(fileName, stripSequence), "")
}

View file

@ -6,39 +6,37 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestBase(t *testing.T) { func TestBasePrefix(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", BasePrefix("", true))
assert.Equal(t, "", BasePrefix("", false))
})
t.Run("Screenshot 2019-05-21 at 10.45.52.png", func(t *testing.T) { t.Run("Screenshot 2019-05-21 at 10.45.52.png", func(t *testing.T) {
regular := BasePrefix("Screenshot 2019-05-21 at 10.45.52.png", false) regular := BasePrefix("Screenshot 2019-05-21 at 10.45.52.png", false)
assert.Equal(t, "Screenshot 2019-05-21 at 10.45.52", regular) assert.Equal(t, "Screenshot 2019-05-21 at 10.45.52", regular)
stripped := BasePrefix("Screenshot 2019-05-21 at 10.45.52.png", true) stripped := BasePrefix("Screenshot 2019-05-21 at 10.45.52.png", true)
assert.Equal(t, "Screenshot 2019-05-21 at 10.45.52", stripped) assert.Equal(t, "Screenshot 2019-05-21 at 10.45.52", stripped)
}) })
t.Run("Test.jpg", func(t *testing.T) { t.Run("Test.jpg", func(t *testing.T) {
result := BasePrefix("/testdata/Test.jpg", true) result := BasePrefix("/testdata/Test.jpg", true)
assert.Equal(t, "Test", result) assert.Equal(t, "Test", result)
}) })
t.Run("Test.jpg.json", func(t *testing.T) { t.Run("Test.jpg.json", func(t *testing.T) {
result := BasePrefix("/testdata/Test.jpg.json", true) result := BasePrefix("/testdata/Test.jpg.json", true)
assert.Equal(t, "Test", result) assert.Equal(t, "Test", result)
}) })
t.Run("Test copy 3.jpg", func(t *testing.T) { t.Run("Test copy 3.jpg", func(t *testing.T) {
result := BasePrefix("/testdata/Test copy 3.jpg", true) result := BasePrefix("/testdata/Test copy 3.jpg", true)
assert.Equal(t, "Test", result) assert.Equal(t, "Test", result)
}) })
t.Run("Test (3).jpg", func(t *testing.T) { t.Run("Test (3).jpg", func(t *testing.T) {
result := BasePrefix("/testdata/Test (3).jpg", true) result := BasePrefix("/testdata/Test (3).jpg", true)
assert.Equal(t, "Test", result) assert.Equal(t, "Test", result)
}) })
t.Run("Test.jpg", func(t *testing.T) { t.Run("Test.jpg", func(t *testing.T) {
result := BasePrefix("/testdata/Test.jpg", false) result := BasePrefix("/testdata/Test.jpg", false)
assert.Equal(t, "Test", result) assert.Equal(t, "Test", result)
}) })
t.Run("Test.3453453.jpg", func(t *testing.T) { t.Run("Test.3453453.jpg", func(t *testing.T) {
regular := BasePrefix("/testdata/Test.3453453.jpg", false) regular := BasePrefix("/testdata/Test.3453453.jpg", false)
assert.Equal(t, "Test.3453453", regular) assert.Equal(t, "Test.3453453", regular)
@ -46,7 +44,6 @@ func TestBase(t *testing.T) {
stripped := BasePrefix("/testdata/Test.3453453.jpg", true) stripped := BasePrefix("/testdata/Test.3453453.jpg", true)
assert.Equal(t, "Test", stripped) assert.Equal(t, "Test", stripped)
}) })
t.Run("/foo/bar.0000.ZIP", func(t *testing.T) { t.Run("/foo/bar.0000.ZIP", func(t *testing.T) {
regular := BasePrefix("/foo/bar.0000.ZIP", false) regular := BasePrefix("/foo/bar.0000.ZIP", false)
assert.Equal(t, "bar.0000", regular) assert.Equal(t, "bar.0000", regular)
@ -54,7 +51,6 @@ func TestBase(t *testing.T) {
stripped := BasePrefix("/foo/bar.0000.ZIP", true) stripped := BasePrefix("/foo/bar.0000.ZIP", true)
assert.Equal(t, "bar.0000", stripped) assert.Equal(t, "bar.0000", stripped)
}) })
t.Run("/foo/bar.00001.ZIP", func(t *testing.T) { t.Run("/foo/bar.00001.ZIP", func(t *testing.T) {
regular := BasePrefix("/foo/bar.00001.ZIP", false) regular := BasePrefix("/foo/bar.00001.ZIP", false)
assert.Equal(t, "bar.00001", regular) assert.Equal(t, "bar.00001", regular)
@ -62,12 +58,10 @@ func TestBase(t *testing.T) {
stripped := BasePrefix("/foo/bar.00001.ZIP", true) stripped := BasePrefix("/foo/bar.00001.ZIP", true)
assert.Equal(t, "bar", stripped) assert.Equal(t, "bar", stripped)
}) })
t.Run("Test copy 3.jpg", func(t *testing.T) { t.Run("Test copy 3.jpg", func(t *testing.T) {
result := BasePrefix("/testdata/Test copy 3.jpg", false) result := BasePrefix("/testdata/Test copy 3.jpg", false)
assert.Equal(t, "Test copy 3", result) assert.Equal(t, "Test copy 3", result)
}) })
t.Run("Test (3).jpg", func(t *testing.T) { t.Run("Test (3).jpg", func(t *testing.T) {
result := BasePrefix("/testdata/Test (3).jpg", false) result := BasePrefix("/testdata/Test (3).jpg", false)
assert.Equal(t, "Test (3)", result) assert.Equal(t, "Test (3)", result)
@ -98,7 +92,11 @@ func TestBase(t *testing.T) {
}) })
} }
func TestRelBase(t *testing.T) { func TestRelPrefix(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", RelPrefix("", "", true))
assert.Equal(t, "", RelPrefix("", "", false))
})
t.Run("/foo/bar.0000.ZIP", func(t *testing.T) { t.Run("/foo/bar.0000.ZIP", func(t *testing.T) {
regular := RelPrefix("/foo/bar.0000.ZIP", "/bar", false) regular := RelPrefix("/foo/bar.0000.ZIP", "/bar", false)
assert.Equal(t, "/foo/bar.0000", regular) assert.Equal(t, "/foo/bar.0000", regular)
@ -106,7 +104,6 @@ func TestRelBase(t *testing.T) {
stripped := RelPrefix("/foo/bar.0000.ZIP", "/bar", true) stripped := RelPrefix("/foo/bar.0000.ZIP", "/bar", true)
assert.Equal(t, "/foo/bar.0000", stripped) assert.Equal(t, "/foo/bar.0000", stripped)
}) })
t.Run("/foo/bar.00001.ZIP", func(t *testing.T) { t.Run("/foo/bar.00001.ZIP", func(t *testing.T) {
regular := RelPrefix("/foo/bar.00001.ZIP", "/bar", false) regular := RelPrefix("/foo/bar.00001.ZIP", "/bar", false)
assert.Equal(t, "/foo/bar.00001", regular) assert.Equal(t, "/foo/bar.00001", regular)
@ -114,33 +111,79 @@ func TestRelBase(t *testing.T) {
stripped := RelPrefix("/foo/bar.00001.ZIP", "/bar", true) stripped := RelPrefix("/foo/bar.00001.ZIP", "/bar", true)
assert.Equal(t, "/foo/bar", stripped) assert.Equal(t, "/foo/bar", stripped)
}) })
t.Run("Test copy 3.jpg", func(t *testing.T) { t.Run("Test copy 3.jpg", func(t *testing.T) {
result := RelPrefix("/testdata/foo/Test copy 3.jpg", "/testdata", false) result := RelPrefix("/testdata/foo/Test copy 3.jpg", "/testdata", false)
assert.Equal(t, "foo/Test copy 3", result) assert.Equal(t, "foo/Test copy 3", result)
}) })
t.Run("Test (3).jpg", func(t *testing.T) { t.Run("Test (3).jpg", func(t *testing.T) {
result := RelPrefix("/testdata/foo/Test (3).jpg", "/testdata", false) result := RelPrefix("/testdata/foo/Test (3).jpg", "/testdata", false)
assert.Equal(t, "foo/Test (3)", result) assert.Equal(t, "foo/Test (3)", result)
}) })
t.Run("Test (3).jpg", func(t *testing.T) { t.Run("Test (3).jpg", func(t *testing.T) {
result := RelPrefix("/testdata/foo/Test (3).jpg", "/testdata/foo/Test (3).jpg", false) result := RelPrefix("/testdata/foo/Test (3).jpg", "/testdata/foo/Test (3).jpg", false)
assert.Equal(t, "Test (3)", result) assert.Equal(t, "Test (3)", result)
}) })
} }
func TestBaseAbs(t *testing.T) { func TestAbsPrefix(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", AbsPrefix("", true))
assert.Equal(t, "", AbsPrefix("", false))
})
t.Run("Test copy 3.jpg", func(t *testing.T) { t.Run("Test copy 3.jpg", func(t *testing.T) {
result := AbsPrefix("/testdata/Test (4).jpg", true) result := AbsPrefix("/testdata/Test (4).jpg", true)
assert.Equal(t, "/testdata/Test", result) assert.Equal(t, "/testdata/Test", result)
}) })
t.Run("Test (3).jpg", func(t *testing.T) { t.Run("Test (3).jpg", func(t *testing.T) {
result := AbsPrefix("/testdata/Test (4).jpg", false) result := AbsPrefix("/testdata/Test (4).jpg", false)
assert.Equal(t, "/testdata/Test (4)", result) assert.Equal(t, "/testdata/Test (4)", result)
}) })
} }
func TestRelatedFilePathPrefix(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Equal(t, "", RelatedFilePathPrefix("", true))
assert.Equal(t, "", RelatedFilePathPrefix("", false))
})
t.Run("IMG_4120", func(t *testing.T) {
assert.Equal(t, "/foo/bar/IMG_4120", RelatedFilePathPrefix("/foo/bar/IMG_4120.JPG", false))
assert.Equal(t, "/foo/bar/IMG_E4120", RelatedFilePathPrefix("/foo/bar/IMG_E4120.JPG", false))
})
t.Run("LivePhoto", func(t *testing.T) {
assert.Equal(t, "IMG_1722", RelatedFilePathPrefix("IMG_1722_HEVC.MOV", false))
assert.Equal(t, "IMG_1722", RelatedFilePathPrefix("IMG_1722_HEVC.MOV", true))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_HevC", false))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_HEVC.MOV", false))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_HEVC.MOV", true))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_hevc.MOV", false))
assert.Equal(t, "/foo/bar/IMG_1722_hevc_", RelatedFilePathPrefix("/foo/bar/IMG_1722_hevc_.MOV", false))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_HEVC.AVC", true))
assert.Equal(t, "/foo/bar/IMG_1722_MOV", RelatedFilePathPrefix("/foo/bar/IMG_1722_MOV.MOV", true))
assert.Equal(t, "/foo/bar/IMG_1722_AVC", RelatedFilePathPrefix("/foo/bar/IMG_1722_AVC.MOV", true))
assert.Equal(t, "IMG_1722", RelatedFilePathPrefix("IMG_1722_HEVC.JPEG", false))
assert.Equal(t, "IMG_1722", RelatedFilePathPrefix("IMG_1722_HEVC.JPEG", true))
assert.Equal(t, "IMG_1722", RelatedFilePathPrefix("IMG_1722_HEVC (1).JPEG", true))
assert.Equal(t, "IMG_1722", RelatedFilePathPrefix("IMG_1722_HEVC (2).JPEG", true))
assert.Equal(t, "IMG_1722", RelatedFilePathPrefix("IMG_1722_JPEG (1).JPEG", true))
assert.Equal(t, "IMG_1722", RelatedFilePathPrefix("IMG_1722_JPG (2).JPEG", true))
assert.Equal(t, "IMG_1722_JPG (2)", RelatedFilePathPrefix("IMG_1722_JPG (2).JPEG", false))
assert.Equal(t, "IMG_1722_AVC", RelatedFilePathPrefix("IMG_1722_AVC (3).JPEG", true))
assert.Equal(t, "IMG_1722_AVC (3)", RelatedFilePathPrefix("IMG_1722_AVC (3).JPEG", false))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_Jpeg", false))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_JPEG.MOV", false))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_JPEG.MOV", true))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_jpeg.MOV", false))
assert.Equal(t, "/foo/bar/IMG_1722_jpeg_", RelatedFilePathPrefix("/foo/bar/IMG_1722_jpeg_.MOV", false))
assert.Equal(t, "/foo/bar/IMG_1722", RelatedFilePathPrefix("/foo/bar/IMG_1722_JPEG.JPEG", false))
})
t.Run("Sequence", func(t *testing.T) {
assert.Equal(t, "/foo/bar/Test", RelatedFilePathPrefix("/foo/bar/Test (4).jpg", true))
assert.Equal(t, "/foo/bar/Test (4)", RelatedFilePathPrefix("/foo/bar/Test (4).jpg", false))
})
t.Run("LowerCase", func(t *testing.T) {
assert.Equal(t, "/foo/bar/IMG_E4120", RelatedFilePathPrefix("/foo/bar/IMG_E4120.JPG", false))
})
}