XMP: Group files based on DocumentID and Instance ID #335

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-05-27 13:40:21 +02:00
parent 301e510b2d
commit f510ac994c
41 changed files with 779 additions and 280 deletions

View file

@ -6,6 +6,7 @@ import Util from "common/util";
export class File extends RestModel {
getDefaults() {
return {
InstanceID: "",
UID: "",
PhotoUID: "",
Root: "",

View file

@ -4,8 +4,8 @@ import {DateTime} from "luxon";
import File from "model/file";
import Util from "../common/util";
export const RootOriginals = "originals";
export const RootImport = "import";
export const RootOriginals = "originals";
export class Folder extends RestModel {
getDefaults() {

View file

@ -14,6 +14,7 @@ export const MonthUnknown = -1;
export class Photo extends RestModel {
getDefaults() {
return {
DocumentID: "",
UID: "",
Type: TypeImage,
Favorite: false,

View file

@ -23,7 +23,7 @@ type FoldersResponse struct {
}
// GetFolders is a reusable request handler for directory listings (GET /api/v1/folders/*).
func GetFolders(router *gin.RouterGroup, conf *config.Config, root, rootPath string) {
func GetFolders(router *gin.RouterGroup, conf *config.Config, urlPath, rootName, rootPath string) {
handler := func(c *gin.Context) {
if Unauthorized(c, conf) {
c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized)
@ -35,7 +35,7 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, root, rootPath str
recursive := c.Query("recursive") != ""
listFiles := c.Query("files") != ""
cached := !listFiles && c.Query("uncached") == ""
resp := FoldersResponse{Root: root, Recursive: recursive, Cached: cached}
resp := FoldersResponse{Root: rootName, Recursive: recursive, Cached: cached}
path := c.Param("path")
cacheKey := fmt.Sprintf("folders:%s:%t:%t", filepath.Join(rootPath, path), recursive, listFiles)
@ -48,7 +48,7 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, root, rootPath str
}
}
if folders, err := query.FoldersByPath(root, rootPath, path, recursive); err != nil {
if folders, err := query.FoldersByPath(rootName, rootPath, path, recursive); err != nil {
log.Errorf("folders: %s", err)
c.JSON(http.StatusOK, resp)
return
@ -57,7 +57,7 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, root, rootPath str
}
if listFiles {
if files, err := query.FilesByPath(root, path); err != nil {
if files, err := query.FilesByPath(rootName, path); err != nil {
log.Errorf("folders: %s", err)
} else {
resp.Files = files
@ -75,16 +75,16 @@ func GetFolders(router *gin.RouterGroup, conf *config.Config, root, rootPath str
c.JSON(http.StatusOK, resp)
}
router.GET("/folders/"+root, handler)
router.GET("/folders/"+root+"/*path", handler)
router.GET("/folders/"+urlPath, handler)
router.GET("/folders/"+urlPath+"/*path", handler)
}
// GET /api/v1/folders/originals
func GetFoldersOriginals(router *gin.RouterGroup, conf *config.Config) {
GetFolders(router, conf, entity.RootOriginals, conf.OriginalsPath())
GetFolders(router, conf, "originals", entity.RootDefault, conf.OriginalsPath())
}
// GET /api/v1/folders/import
func GetFoldersImport(router *gin.RouterGroup, conf *config.Config) {
GetFolders(router, conf, entity.RootImport, conf.ImportPath())
GetFolders(router, conf, "import", entity.RootImport, conf.ImportPath())
}

View file

@ -46,7 +46,7 @@ func TestGetFoldersOriginals(t *testing.T) {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.TypeDefault, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, entity.RootOriginals, folder.Root)
assert.Equal(t, entity.RootDefault, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
assert.Equal(t, false, folder.FolderIgnore)
@ -85,7 +85,7 @@ func TestGetFoldersOriginals(t *testing.T) {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.TypeDefault, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, entity.RootOriginals, folder.Root)
assert.Equal(t, entity.RootDefault, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
assert.Equal(t, false, folder.FolderIgnore)

View file

@ -41,7 +41,7 @@ type Album struct {
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Album) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsPPID(m.AlbumUID, 'a') {
if rnd.IsUID(m.AlbumUID, 'a') {
return nil
}

View file

@ -36,7 +36,7 @@ const (
TypeRaw = "raw"
TypeText = "text"
RootOriginals = "originals"
RootImport = "import"
RootPath = "/"
RootDefault = ""
RootImport = "import"
RootPath = "/"
)

View file

@ -12,15 +12,16 @@ import (
"github.com/ulule/deepcopier"
)
// File represents an image or sidecar file that belongs to a photo
// File represents an image or sidecar file that belongs to a photo.
type File struct {
ID uint `gorm:"primary_key" json:"-" yaml:"-"`
InstanceID string `gorm:"type:varbinary(36);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"`
Photo *Photo `json:"-" yaml:"-"`
PhotoID uint `gorm:"index;" json:"-" yaml:"-"`
PhotoUID string `gorm:"type:varbinary(36);index;" json:"PhotoUID" yaml:"PhotoUID"`
FileUID string `gorm:"type:varbinary(36);unique_index;" json:"UID" yaml:"UID"`
FileName string `gorm:"type:varbinary(768);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
FileRoot string `gorm:"type:varbinary(16);default:'originals';unique_index:idx_files_name_root;" json:"Root" yaml:"Root"`
FileRoot string `gorm:"type:varbinary(16);default:'';unique_index:idx_files_name_root;" json:"Root" yaml:"Root,omitempty"`
OriginalName string `gorm:"type:varbinary(768);" json:"OriginalName" yaml:"OriginalName,omitempty"`
FileHash string `gorm:"type:varbinary(128);index" json:"Hash" yaml:"Hash,omitempty"`
FileModified time.Time `json:"Modified" yaml:"Modified,omitempty"`
@ -79,7 +80,7 @@ func FirstFileByHash(fileHash string) (File, error) {
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *File) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsPPID(m.FileUID, 'f') {
if rnd.IsUID(m.FileUID, 'f') {
return nil
}

View file

@ -17,7 +17,7 @@ import (
// Folder represents a file system directory.
type Folder struct {
Path string `gorm:"type:varbinary(255);unique_index:idx_folders_path_root;" json:"Path" yaml:"Path"`
Root string `gorm:"type:varbinary(16);default:'originals';unique_index:idx_folders_path_root;" json:"Root" yaml:"Root"`
Root string `gorm:"type:varbinary(16);default:'';unique_index:idx_folders_path_root;" json:"Root" yaml:"Root,omitempty"`
FolderUID string `gorm:"type:varbinary(36);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
FolderType string `gorm:"type:varbinary(16);" json:"Type" yaml:"Type,omitempty"`
FolderTitle string `gorm:"type:varchar(255);" json:"Title" yaml:"Title,omitempty"`
@ -41,7 +41,7 @@ type Folder struct {
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Folder) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsPPID(m.FolderUID, 'd') {
if rnd.IsUID(m.FolderUID, 'd') {
return nil
}
@ -80,7 +80,12 @@ func (m *Folder) SetTitleFromPath() {
s = strings.TrimSpace(s)
if s == "" || s == RootPath {
s = m.Root
if m.Root == RootDefault {
m.FolderTitle = "Originals"
return
} else {
s = m.Root
}
} else {
s = path.Base(s)
}

View file

@ -8,8 +8,8 @@ import (
func TestNewFolder(t *testing.T) {
t.Run("2020/05", func(t *testing.T) {
folder := NewFolder(RootOriginals, "2020/05", nil)
assert.Equal(t, RootOriginals, folder.Root)
folder := NewFolder(RootDefault, "2020/05", nil)
assert.Equal(t, RootDefault, folder.Root)
assert.Equal(t, "2020/05", folder.Path)
assert.Equal(t, "May 2020", folder.FolderTitle)
assert.Equal(t, "", folder.FolderDescription)
@ -22,7 +22,7 @@ func TestNewFolder(t *testing.T) {
})
t.Run("/2020/05/01/", func(t *testing.T) {
folder := NewFolder(RootOriginals, "/2020/05/01/", nil)
folder := NewFolder(RootDefault, "/2020/05/01/", nil)
assert.Equal(t, "2020/05/01", folder.Path)
assert.Equal(t, "May 2020", folder.FolderTitle)
})
@ -34,19 +34,19 @@ func TestNewFolder(t *testing.T) {
})
t.Run("/2020/05/23 Birthday", func(t *testing.T) {
folder := NewFolder(RootOriginals, "/2020/05/23 Birthday", nil)
folder := NewFolder(RootDefault, "/2020/05/23 Birthday", nil)
assert.Equal(t, "2020/05/23 Birthday", folder.Path)
assert.Equal(t, "23 Birthday", folder.FolderTitle)
})
t.Run("empty", func(t *testing.T) {
folder := NewFolder(RootOriginals, "", nil)
folder := NewFolder(RootDefault, "", nil)
assert.Equal(t, "", folder.Path)
assert.Equal(t, "Originals", folder.FolderTitle)
})
t.Run("root", func(t *testing.T) {
folder := NewFolder(RootOriginals, RootPath, nil)
folder := NewFolder(RootDefault, RootPath, nil)
assert.Equal(t, "", folder.Path)
assert.Equal(t, "Originals", folder.FolderTitle)
})

View file

@ -33,7 +33,7 @@ type Label struct {
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Label) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsPPID(m.LabelUID, 'l') {
if rnd.IsUID(m.LabelUID, 'l') {
return nil
}

View file

@ -18,6 +18,7 @@ import (
// Photo represents a photo, all its properties, and link to all its images and sidecar files.
type Photo struct {
ID uint `gorm:"primary_key" yaml:"-"`
DocumentID string `gorm:"type:varbinary(36);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"`
TakenAt time.Time `gorm:"type:datetime;index:idx_photos_taken_uid;" json:"TakenAt" yaml:"TakenAt"`
TakenAtLocal time.Time `gorm:"type:datetime;" yaml:"-"`
TakenSrc string `gorm:"type:varbinary(8);" json:"TakenSrc" yaml:"TakenSrc,omitempty"`
@ -192,7 +193,7 @@ func (m *Photo) BeforeCreate(scope *gorm.Scope) error {
}
}
if rnd.IsPPID(m.PhotoUID, 'p') {
if rnd.IsUID(m.PhotoUID, 'p') {
return nil
}
@ -391,8 +392,9 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
knownLocation = true
loc := m.Location
if title := labels.Title(loc.Name()); title != "" { // TODO: User defined title format
log.Infof("photo: using label %s to create photo title", txt.Quote(title))
// TODO: User defined title format
if title := labels.Title(loc.Name()); title != "" {
log.Infof("photo: using label %s to create title for %s", txt.Quote(title), m.PhotoUID)
if loc.NoCity() || loc.LongCity() || loc.CityContains(title) {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), loc.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
@ -417,7 +419,7 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
knownLocation = true
if title := labels.Title(""); title != "" {
log.Infof("photo: using label %s to create photo title", txt.Quote(title))
log.Infof("photo: using label %s to create title for %s", txt.Quote(title), m.PhotoUID)
if m.Place.NoCity() || m.Place.LongCity() || m.Place.CityContains(title) {
m.SetTitle(fmt.Sprintf("%s / %s / %s", txt.Title(title), m.Place.CountryName(), m.TakenAt.Format("2006")), SrcAuto)
} else {
@ -445,9 +447,9 @@ func (m *Photo) UpdateTitle(labels classify.Labels) error {
m.SetTitle(TitleUnknown, SrcAuto)
}
log.Infof("photo: changed photo title to %s", txt.Quote(m.PhotoTitle))
log.Infof("photo: changed title of %s to %s", m.PhotoUID, txt.Quote(m.PhotoTitle))
} else {
log.Infof("photo: new title is %s", txt.Quote(m.PhotoTitle))
log.Infof("photo: new title of %s is %s", m.PhotoUID, txt.Quote(m.PhotoTitle))
}
return nil

View file

@ -4,7 +4,7 @@ import "github.com/jinzhu/gorm"
// UpdatePhotoCounts updates photos count in related tables as needed.
func UpdatePhotoCounts() error {
log.Info("index: updating photo counts")
// log.Info("index: updating photo counts")
if err := Db().Table("places").
UpdateColumn("photo_count", gorm.Expr("(SELECT COUNT(*) FROM photos ph "+

View file

@ -3,6 +3,8 @@ package meta
const CodecUnknown = ""
const CodecJpeg = "jpeg"
const CodecAvc1 = "avc1"
const CodecHeic = "heic"
const CodecXMP = "xmp"
// CodecAvc1 returns true if the video is encoded with H.264/AVC
func (data Data) CodecAvc1() bool {

View file

@ -7,12 +7,13 @@ import (
// Data represents image meta data.
type Data struct {
UniqueID string `meta:"ImageUniqueID"`
DocumentID string `meta:"ImageUniqueID,OriginalDocumentID,DocumentID"`
InstanceID string `meta:"InstanceID,DocumentID"`
TakenAt time.Time `meta:"DateTimeOriginal,CreateDate,MediaCreateDate,DateTimeDigitized,DateTime"`
TakenAtLocal time.Time `meta:"DateTimeOriginal,CreateDate,MediaCreateDate,DateTimeDigitized,DateTime"`
TimeZone string `meta:"-"`
Duration time.Duration `meta:"Duration,MediaDuration,TrackDuration"`
Codec string `meta:"CompressorID,Compression"`
Codec string `meta:"CompressorID,Compression,FileType"`
Title string `meta:"Title"`
Subject string `meta:"Subject,PersonInImage"`
Keywords string `meta:"Keywords"`
@ -42,6 +43,7 @@ type Data struct {
Height int `meta:"PixelYDimension,ImageHeight,ImageLength,ExifImageHeight,SourceImageHeight"`
Orientation int `meta:"-"`
Rotation int `meta:"Rotation"`
Error error `meta:"-"`
All map[string]string
}
@ -68,3 +70,18 @@ func (data Data) Portrait() bool {
func (data Data) Megapixels() int {
return int(math.Round(float64(data.Width*data.Height) / 1000000))
}
// HasDocumentID returns true if a DocumentID exists.
func (data Data) HasDocumentID() bool {
return len(data.DocumentID) >= 15
}
// HasInstanceID returns true if an InstanceID exists.
func (data Data) HasInstanceID() bool {
return len(data.InstanceID) >= 15
}
// HasTimeAndPlace if data contains a time and gps position.
func (data Data) HasTimeAndPlace() bool {
return !data.TakenAt.IsZero() && data.Lat != 0 && data.Lng != 0
}

View file

@ -25,12 +25,6 @@ func ValidDateTime(s string) bool {
return len(s) == len(DateTimeZero) && s != DateTimeZero
}
// SanitizeString removes unwanted character from an exif value string.
func SanitizeString(value string) string {
value = strings.TrimSpace(value)
return strings.Replace(value, "\"", "", -1)
}
// Exif parses an image file for Exif meta data and returns as Data struct.
func Exif(fileName string) (data Data, err error) {
err = data.Exif(fileName)
@ -254,7 +248,7 @@ func (data *Data) Exif(fileName string) (err error) {
}
if value, ok := tags["ImageUniqueID"]; ok {
data.UniqueID = value
data.DocumentID = SanitizeUID(value)
}
if value, ok := tags["PixelXDimension"]; ok {

View file

@ -144,5 +144,15 @@ func (data *Data) JSON(fileName string) (err error) {
data.Codec = CodecJpeg
}
// Validate and normalize optional DocumentID.
if len(data.DocumentID) > 0 {
data.DocumentID = SanitizeUID(data.DocumentID)
}
// Validate and normalize optional InstanceID.
if len(data.InstanceID) > 0 {
data.InstanceID = SanitizeUID(data.InstanceID)
}
return nil
}

View file

@ -128,7 +128,7 @@ func TestJSON(t *testing.T) {
// t.Logf("DATA: %+v", data)
assert.Equal(t, CodecUnknown, data.Codec)
assert.Equal(t, CodecXMP, data.Codec)
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, float32(52.45969), data.Lat)
assert.Equal(t, float32(13.321831), data.Lng)
@ -212,7 +212,7 @@ func TestJSON(t *testing.T) {
// t.Logf("DATA: %+v", data)
assert.Equal(t, CodecUnknown, data.Codec)
assert.Equal(t, CodecHeic, data.Codec)
assert.Equal(t, "", data.Title)
assert.Equal(t, "", data.Artist)
assert.Equal(t, "", data.Description)
@ -223,4 +223,78 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "iPhone 7 back camera 3.99mm f/1.8", data.LensModel)
})
t.Run("uuid-original.json", func(t *testing.T) {
data, err := JSON("testdata/uuid-original.json")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %+v", data)
assert.Equal(t, "9bafc58c-6c82-4e66-a45f-c13f915f99c5", data.DocumentID)
assert.Equal(t, "", data.InstanceID)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2018-12-06 12:32:26 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2018-12-06 11:32:26 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 3024, data.Width)
assert.Equal(t, 4032, data.Height)
assert.Equal(t, float32(48.300003), data.Lat)
assert.Equal(t, float32(8.929067), data.Lng)
assert.Equal(t, "Apple", data.CameraMake)
assert.Equal(t, "iPhone SE", data.CameraModel)
assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", data.LensModel)
})
t.Run("uuid-copy.json", func(t *testing.T) {
data, err := JSON("testdata/uuid-copy.json")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %+v", data)
assert.Equal(t, "4b1fef2d1cf4a5be38b263e0637edead", data.DocumentID)
assert.Equal(t, "dafbfeb8-a129-4e7c-9cf0-e7996a701cdb", data.InstanceID)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2018-12-06 12:32:26 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2018-12-06 11:32:26 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1024, data.Width)
assert.Equal(t, 1365, data.Height)
assert.Equal(t, float32(48.300003), data.Lat)
assert.Equal(t, float32(8.929067), data.Lng)
assert.Equal(t, "Apple", data.CameraMake)
assert.Equal(t, "iPhone SE", data.CameraModel)
assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", data.LensModel)
})
t.Run("uuid-imagemagick.json", func(t *testing.T) {
data, err := JSON("testdata/uuid-imagemagick.json")
if err != nil {
t.Fatal(err)
}
// t.Logf("DATA: %+v", data)
assert.Equal(t, "9bafc58c-6c82-4e66-a45f-c13f915f99c5", data.DocumentID)
assert.Equal(t, "", data.InstanceID)
assert.Equal(t, CodecJpeg, data.Codec)
assert.Equal(t, "0s", data.Duration.String())
assert.Equal(t, "2018-12-06 12:32:26 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, "2018-12-06 11:32:26 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, 1125, data.Width)
assert.Equal(t, 1500, data.Height)
assert.Equal(t, float32(48.300003), data.Lat)
assert.Equal(t, float32(8.929067), data.Lng)
assert.Equal(t, "Apple", data.CameraMake)
assert.Equal(t, "iPhone SE", data.CameraModel)
assert.Equal(t, "iPhone SE back camera 4.15mm f/2.2", data.LensModel)
})
}

27
internal/meta/sanitize.go Normal file
View file

@ -0,0 +1,27 @@
package meta
import (
"strings"
)
// SanitizeString removes unwanted character from an exif value string.
func SanitizeString(value string) string {
value = strings.TrimSpace(value)
return strings.Replace(value, "\"", "", -1)
}
// SanitizeUID normalizes unique IDs found in XMP or Exif metadata.
func SanitizeUID(value string) string {
value = SanitizeString(value)
if start := strings.LastIndex(value, ":"); start != -1 {
value = value[start+1:]
}
// Not a unique ID?
if len(value) < 15 || len(value) > 36 {
value = ""
}
return strings.ToLower(value)
}

179
internal/meta/testdata/uuid-copy.json vendored Normal file
View file

@ -0,0 +1,179 @@
[{
"SourceFile": "20181206-123226-Toy-Bisingen-2018-copy.jpg",
"ExifToolVersion": 11.85,
"FileName": "20181206-123226-Toy-Bisingen-2018-copy.jpg",
"Directory": ".",
"FileSize": "242 kB",
"FileModifyDate": "2020:05:27 10:32:16+02:00",
"FileAccessDate": "2020:05:27 10:32:18+02:00",
"FileInodeChangeDate": "2020:05:27 10:32:16+02:00",
"FilePermissions": "rw-r--r--",
"FileType": "JPEG",
"FileTypeExtension": "jpg",
"MIMEType": "image/jpeg",
"ExifByteOrder": "Big-endian (Motorola, MM)",
"PhotometricInterpretation": "RGB",
"Make": "Apple",
"Model": "iPhone SE",
"Orientation": "Horizontal (normal)",
"SamplesPerPixel": 3,
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": "inches",
"Software": "Adobe Photoshop 21.1 (Macintosh)",
"ModifyDate": "2020:05:27 10:32:12",
"ExposureTime": "1/185",
"FNumber": 2.2,
"ExposureProgram": "Program AE",
"ISO": 25,
"ExifVersion": "0221",
"DateTimeOriginal": "2018:12:06 12:32:26",
"CreateDate": "2018:12:06 12:32:26",
"ComponentsConfiguration": "Y, Cb, Cr, -",
"ShutterSpeedValue": "1/185",
"ApertureValue": 2.2,
"BrightnessValue": 6.784544005,
"ExposureCompensation": 0,
"MeteringMode": "Multi-segment",
"Flash": "Off, Did not fire",
"FocalLength": "4.2 mm",
"SubjectArea": "2015 1511 2217 1330",
"SubSecTimeOriginal": 600,
"SubSecTimeDigitized": 600,
"FlashpixVersion": "0100",
"ColorSpace": "sRGB",
"ExifImageWidth": 1024,
"ExifImageHeight": 1365,
"SensingMethod": "One-chip color area",
"SceneType": "Directly photographed",
"CustomRendered": "HDR (original saved)",
"ExposureMode": "Auto",
"WhiteBalance": "Auto",
"FocalLengthIn35mmFormat": "29 mm",
"SceneCaptureType": "Standard",
"LensInfo": "4.150000095mm f/2.2",
"LensMake": "Apple",
"LensModel": "iPhone SE back camera 4.15mm f/2.2",
"GPSLatitudeRef": "North",
"GPSLongitudeRef": "East",
"GPSAltitudeRef": "Above Sea Level",
"GPSTimeStamp": "11:32:25",
"GPSSpeedRef": "km/h",
"GPSSpeed": 0,
"GPSImgDirectionRef": "True North",
"GPSImgDirection": 105.7627258,
"GPSDestBearingRef": "True North",
"GPSDestBearing": 105.7627258,
"GPSDateStamp": "2018:12:06",
"GPSHPositioningError": "5 m",
"Compression": "JPEG (old-style)",
"ThumbnailOffset": 1274,
"ThumbnailLength": 7386,
"CurrentIPTCDigest": "c166ecde819b2c0da5a3082cbe444b5c",
"ApplicationRecordVersion": 0,
"TimeCreated": "12:32:26",
"IPTCDigest": "c166ecde819b2c0da5a3082cbe444b5c",
"DisplayedUnitsX": "inches",
"DisplayedUnitsY": "inches",
"PrintStyle": "Centered",
"PrintPosition": "0 0",
"PrintScale": 1,
"GlobalAngle": 30,
"GlobalAltitude": 30,
"URL_List": [],
"SlicesGroupName": "20181206-123226-Toy-Bisingen-2018-1du",
"NumSlices": 1,
"PixelAspectRatio": 1,
"PhotoshopThumbnail": "(Binary data 7386 bytes, use -b option to extract)",
"HasRealMergedData": "Yes",
"WriterName": "Adobe Photoshop",
"ReaderName": "Adobe Photoshop 2020",
"PhotoshopQuality": 5,
"PhotoshopFormat": "Progressive",
"ProgressiveScans": "3 Scans",
"XMPToolkit": "Adobe XMP Core 6.0-c002 79.164352, 2020/01/30-15:50:38 ",
"CreatorTool": 12.1,
"MetadataDate": "2020:05:27 10:32:12+02:00",
"DateCreated": "2018:12:06 12:32:26",
"LegacyIPTCDigest": "D41D8CD98F00B204E9800998ECF8427E",
"ColorMode": "RGB",
"ICCProfileName": "sRGB IEC61966-2.1",
"Lens": "iPhone SE back camera 4.15mm f/2.2",
"DocumentID": "adobe:docid:photoshop:59836323-aab2-2049-9a3d-bb9c2455a265",
"InstanceID": "xmp.iid:dafbfeb8-a129-4e7c-9cf0-e7996a701cdb",
"OriginalDocumentID": "4B1FEF2D1CF4A5BE38B263E0637EDEAD",
"Format": "image/jpeg",
"HistoryAction": ["saved","saved"],
"HistoryInstanceID": ["xmp.iid:74b46793-af66-441d-8c03-985a5792dbeb","xmp.iid:dafbfeb8-a129-4e7c-9cf0-e7996a701cdb"],
"HistoryWhen": ["2020:05:27 10:32:12+02:00","2020:05:27 10:32:12+02:00"],
"HistorySoftwareAgent": ["Adobe Photoshop 21.1 (Macintosh)","Adobe Photoshop 21.1 (Macintosh)"],
"HistoryChanged": ["/","/"],
"ProfileCMMType": "Linotronic",
"ProfileVersion": "2.1.0",
"ProfileClass": "Display Device Profile",
"ColorSpaceData": "RGB ",
"ProfileConnectionSpace": "XYZ ",
"ProfileDateTime": "1998:02:09 06:49:00",
"ProfileFileSignature": "acsp",
"PrimaryPlatform": "Microsoft Corporation",
"CMMFlags": "Not Embedded, Independent",
"DeviceManufacturer": "Hewlett-Packard",
"DeviceModel": "sRGB",
"DeviceAttributes": "Reflective, Glossy, Positive, Color",
"RenderingIntent": "Perceptual",
"ConnectionSpaceIlluminant": "0.9642 1 0.82491",
"ProfileCreator": "Hewlett-Packard",
"ProfileID": 0,
"ProfileCopyright": "Copyright (c) 1998 Hewlett-Packard Company",
"ProfileDescription": "sRGB IEC61966-2.1",
"MediaWhitePoint": "0.95045 1 1.08905",
"MediaBlackPoint": "0 0 0",
"RedMatrixColumn": "0.43607 0.22249 0.01392",
"GreenMatrixColumn": "0.38515 0.71687 0.09708",
"BlueMatrixColumn": "0.14307 0.06061 0.7141",
"DeviceMfgDesc": "IEC http://www.iec.ch",
"DeviceModelDesc": "IEC 61966-2.1 Default RGB colour space - sRGB",
"ViewingCondDesc": "Reference Viewing Condition in IEC61966-2.1",
"ViewingCondIlluminant": "19.6445 20.3718 16.8089",
"ViewingCondSurround": "3.92889 4.07439 3.36179",
"ViewingCondIlluminantType": "D50",
"Luminance": "76.03647 80 87.12462",
"MeasurementObserver": "CIE 1931",
"MeasurementBacking": "0 0 0",
"MeasurementGeometry": "Unknown",
"MeasurementFlare": "0.999%",
"MeasurementIlluminant": "D65",
"Technology": "Cathode Ray Tube Display",
"RedTRC": "(Binary data 2060 bytes, use -b option to extract)",
"GreenTRC": "(Binary data 2060 bytes, use -b option to extract)",
"BlueTRC": "(Binary data 2060 bytes, use -b option to extract)",
"DCTEncodeVersion": 100,
"APP14Flags0": "Encoded with Blend=1 downsampling",
"APP14Flags1": "(none)",
"ColorTransform": "YCbCr",
"ImageWidth": 1024,
"ImageHeight": 1365,
"EncodingProcess": "Progressive DCT, Huffman coding",
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "YCbCr4:2:0 (2 2)",
"Aperture": 2.2,
"ImageSize": "1024x1365",
"Megapixels": 1.4,
"ScaleFactor35efl": 7.0,
"ShutterSpeed": "1/185",
"SubSecCreateDate": "2018:12:06 12:32:26.600",
"SubSecDateTimeOriginal": "2018:12:06 12:32:26.600",
"ThumbnailImage": "(Binary data 7386 bytes, use -b option to extract)",
"GPSAltitude": "591.3 m Above Sea Level",
"GPSDateTime": "2018:12:06 11:32:25Z",
"GPSLatitude": "48 deg 18' 0.01\" N",
"GPSLongitude": "8 deg 55' 44.64\" E",
"DateTimeCreated": "2018:12:06 12:32:26",
"CircleOfConfusion": "0.004 mm",
"FOV": "63.7 deg",
"FocalLength35efl": "4.2 mm (35 mm equivalent: 29.0 mm)",
"GPSPosition": "48 deg 18' 0.01\" N, 8 deg 55' 44.64\" E",
"HyperfocalDistance": "1.82 m",
"LightValue": 11.8
}]

View file

@ -0,0 +1,103 @@
[{
"SourceFile": "20181206-123226-Toy-Bisingen-2018-imagemagick.jpg",
"ExifToolVersion": 11.85,
"FileName": "20181206-123226-Toy-Bisingen-2018-imagemagick.jpg",
"Directory": ".",
"FileSize": "545 kB",
"FileModifyDate": "2020:05:27 11:02:01+02:00",
"FileAccessDate": "2020:05:27 11:02:01+02:00",
"FileInodeChangeDate": "2020:05:27 11:02:01+02:00",
"FilePermissions": "rw-r--r--",
"FileType": "JPEG",
"FileTypeExtension": "jpg",
"MIMEType": "image/jpeg",
"JFIFVersion": 1.01,
"CurrentIPTCDigest": "d41d8cd98f00b204e9800998ecf8427e",
"IPTCDigest": "d41d8cd98f00b204e9800998ecf8427e",
"ExifByteOrder": "Big-endian (Motorola, MM)",
"Make": "Apple",
"Model": "iPhone SE",
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": "inches",
"Software": 12.1,
"ModifyDate": "2018:12:06 12:32:26",
"ExposureTime": "1/185",
"FNumber": 2.2,
"ExposureProgram": "Program AE",
"ISO": 25,
"ExifVersion": "0221",
"DateTimeOriginal": "2018:12:06 12:32:26",
"CreateDate": "2018:12:06 12:32:26",
"ComponentsConfiguration": "Y, Cb, Cr, -",
"ShutterSpeedValue": "1/185",
"ApertureValue": 2.2,
"BrightnessValue": 6.784544005,
"ExposureCompensation": 0,
"MeteringMode": "Multi-segment",
"Flash": "Off, Did not fire",
"FocalLength": "4.2 mm",
"SubjectArea": "2015 1511 2217 1330",
"RunTimeFlags": "Valid",
"RunTimeValue": 540640445748916,
"RunTimeScale": 1000000000,
"RunTimeEpoch": 0,
"AccelerationVector": "0.04416524988 0.9474721547 -0.2289867999",
"HDRImageType": "HDR Image",
"ImageUniqueID": "9BAFC58C-6C82-4E66-A45F-C13F915F99C5",
"SubSecTimeOriginal": 600,
"SubSecTimeDigitized": 600,
"FlashpixVersion": "0100",
"ColorSpace": "sRGB",
"ExifImageWidth": 3024,
"ExifImageHeight": 4032,
"SensingMethod": "One-chip color area",
"SceneType": "Directly photographed",
"CustomRendered": "HDR (original saved)",
"ExposureMode": "Auto",
"WhiteBalance": "Auto",
"FocalLengthIn35mmFormat": "29 mm",
"SceneCaptureType": "Standard",
"LensInfo": "4.150000095mm f/2.2",
"LensMake": "Apple",
"LensModel": "iPhone SE back camera 4.15mm f/2.2",
"GPSLatitudeRef": "North",
"GPSLongitudeRef": "East",
"GPSAltitudeRef": "Above Sea Level",
"GPSTimeStamp": "11:32:25",
"GPSSpeedRef": "km/h",
"GPSSpeed": 0,
"GPSImgDirectionRef": "True North",
"GPSImgDirection": 105.7627258,
"GPSDestBearingRef": "True North",
"GPSDestBearing": 105.7627258,
"GPSDateStamp": "2018:12:06",
"GPSHPositioningError": "5 m",
"XMPToolkit": "XMP Core 5.4.0",
"CreatorTool": 12.1,
"DateCreated": "2018:12:06 12:32:26",
"ImageWidth": 1125,
"ImageHeight": 1500,
"EncodingProcess": "Baseline DCT, Huffman coding",
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "YCbCr4:2:0 (2 2)",
"RunTimeSincePowerUp": "6 days 6:10:40",
"Aperture": 2.2,
"ImageSize": "1125x1500",
"Megapixels": 1.7,
"ScaleFactor35efl": 7.0,
"ShutterSpeed": "1/185",
"SubSecCreateDate": "2018:12:06 12:32:26.600",
"SubSecDateTimeOriginal": "2018:12:06 12:32:26.600",
"GPSAltitude": "591.3 m Above Sea Level",
"GPSDateTime": "2018:12:06 11:32:25Z",
"GPSLatitude": "48 deg 18' 0.01\" N",
"GPSLongitude": "8 deg 55' 44.64\" E",
"CircleOfConfusion": "0.004 mm",
"FOV": "63.7 deg",
"FocalLength35efl": "4.2 mm (35 mm equivalent: 29.0 mm)",
"GPSPosition": "48 deg 18' 0.01\" N, 8 deg 55' 44.64\" E",
"HyperfocalDistance": "1.82 m",
"LightValue": 11.8
}]

View file

@ -0,0 +1,103 @@
[{
"SourceFile": "20181206-123226-Toy-Bisingen-2018-1du.jpg",
"ExifToolVersion": 11.85,
"FileName": "20181206-123226-Toy-Bisingen-2018-1du.jpg",
"Directory": ".",
"FileSize": "4.1 MB",
"FileModifyDate": "2020:05:27 10:29:23+02:00",
"FileAccessDate": "2020:05:27 10:31:20+02:00",
"FileInodeChangeDate": "2020:05:27 10:31:17+02:00",
"FilePermissions": "rw-r--r--",
"FileType": "JPEG",
"FileTypeExtension": "jpg",
"MIMEType": "image/jpeg",
"JFIFVersion": 1.01,
"ExifByteOrder": "Big-endian (Motorola, MM)",
"Make": "Apple",
"Model": "iPhone SE",
"XResolution": 72,
"YResolution": 72,
"ResolutionUnit": "inches",
"Software": 12.1,
"ModifyDate": "2018:12:06 12:32:26",
"ExposureTime": "1/185",
"FNumber": 2.2,
"ExposureProgram": "Program AE",
"ISO": 25,
"ExifVersion": "0221",
"DateTimeOriginal": "2018:12:06 12:32:26",
"CreateDate": "2018:12:06 12:32:26",
"ComponentsConfiguration": "Y, Cb, Cr, -",
"ShutterSpeedValue": "1/185",
"ApertureValue": 2.2,
"BrightnessValue": 6.784544005,
"ExposureCompensation": 0,
"MeteringMode": "Multi-segment",
"Flash": "Off, Did not fire",
"FocalLength": "4.2 mm",
"SubjectArea": "2015 1511 2217 1330",
"RunTimeFlags": "Valid",
"RunTimeValue": 540640445748916,
"RunTimeScale": 1000000000,
"RunTimeEpoch": 0,
"AccelerationVector": "0.04416524988 0.9474721547 -0.2289867999",
"HDRImageType": "HDR Image",
"ImageUniqueID": "9BAFC58C-6C82-4E66-A45F-C13F915F99C5",
"SubSecTimeOriginal": 600,
"SubSecTimeDigitized": 600,
"FlashpixVersion": "0100",
"ColorSpace": "sRGB",
"ExifImageWidth": 3024,
"ExifImageHeight": 4032,
"SensingMethod": "One-chip color area",
"SceneType": "Directly photographed",
"CustomRendered": "HDR (original saved)",
"ExposureMode": "Auto",
"WhiteBalance": "Auto",
"FocalLengthIn35mmFormat": "29 mm",
"SceneCaptureType": "Standard",
"LensInfo": "4.150000095mm f/2.2",
"LensMake": "Apple",
"LensModel": "iPhone SE back camera 4.15mm f/2.2",
"GPSLatitudeRef": "North",
"GPSLongitudeRef": "East",
"GPSAltitudeRef": "Above Sea Level",
"GPSTimeStamp": "11:32:25",
"GPSSpeedRef": "km/h",
"GPSSpeed": 0,
"GPSImgDirectionRef": "True North",
"GPSImgDirection": 105.7627258,
"GPSDestBearingRef": "True North",
"GPSDestBearing": 105.7627258,
"GPSDateStamp": "2018:12:06",
"GPSHPositioningError": "5 m",
"XMPToolkit": "XMP Core 5.4.0",
"CreatorTool": 12.1,
"DateCreated": "2018:12:06 12:32:26",
"CurrentIPTCDigest": "d41d8cd98f00b204e9800998ecf8427e",
"IPTCDigest": "d41d8cd98f00b204e9800998ecf8427e",
"ImageWidth": 3024,
"ImageHeight": 4032,
"EncodingProcess": "Baseline DCT, Huffman coding",
"BitsPerSample": 8,
"ColorComponents": 3,
"YCbCrSubSampling": "YCbCr4:2:0 (2 2)",
"RunTimeSincePowerUp": "6 days 6:10:40",
"Aperture": 2.2,
"ImageSize": "3024x4032",
"Megapixels": 12.2,
"ScaleFactor35efl": 7.0,
"ShutterSpeed": "1/185",
"SubSecCreateDate": "2018:12:06 12:32:26.600",
"SubSecDateTimeOriginal": "2018:12:06 12:32:26.600",
"GPSAltitude": "591.3 m Above Sea Level",
"GPSDateTime": "2018:12:06 11:32:25Z",
"GPSLatitude": "48 deg 18' 0.01\" N",
"GPSLongitude": "8 deg 55' 44.64\" E",
"CircleOfConfusion": "0.004 mm",
"FOV": "63.7 deg",
"FocalLength35efl": "4.2 mm (35 mm equivalent: 29.0 mm)",
"GPSPosition": "48 deg 18' 0.01\" N, 8 deg 55' 44.64\" E",
"HyperfocalDistance": "1.82 m",
"LightValue": 11.8
}]

View file

@ -49,13 +49,7 @@ func TestConvert_ToJpeg(t *testing.T) {
assert.Equal(t, jpegFile.FileName(), outputName)
assert.Truef(t, fs.FileExists(jpegFile.FileName()), "output file does not exist: %s", jpegFile.FileName())
metaData, err := jpegFile.MetaData()
if err != nil {
t.Log(err)
} else {
t.Logf("video metadata: %+v", metaData)
}
t.Logf("video metadata: %+v", jpegFile.MetaData())
_ = os.Remove(outputName)
})
@ -79,11 +73,7 @@ func TestConvert_ToJpeg(t *testing.T) {
t.Fatal(err)
}
infoJpeg, err := imageJpeg.MetaData()
if err != nil {
t.Fatalf("%s for %s", err.Error(), imageJpeg.FileName())
}
infoJpeg := imageJpeg.MetaData()
assert.Equal(t, jpegFilename, imageJpeg.fileName)
@ -113,7 +103,7 @@ func TestConvert_ToJpeg(t *testing.T) {
assert.NotEqual(t, rawFilename, imageRaw.fileName)
infoRaw, err := imageRaw.MetaData()
infoRaw := imageRaw.MetaData()
assert.Equal(t, "Canon EOS 6D", infoRaw.CameraModel)
})
@ -246,7 +236,7 @@ func TestConvert_Start(t *testing.T) {
assert.Equal(t, jpegFilename, image.fileName, "FileName must be the same")
infoRaw, err := image.MetaData()
infoRaw := image.MetaData()
assert.Equal(t, "Canon EOS 6D", infoRaw.CameraModel, "UpdateCamera model should be Canon EOS M10")
@ -254,9 +244,11 @@ func TestConvert_Start(t *testing.T) {
oldHash := fs.Hash(existingJpegFilename)
os.Remove(existingJpegFilename)
_ = os.Remove(existingJpegFilename)
convert.Start(conf.ImportPath())
if err := convert.Start(conf.ImportPath()); err != nil {
t.Fatal(err)
}
newHash := fs.Hash(existingJpegFilename)

View file

@ -121,7 +121,7 @@ func (ind *Index) Start(opt IndexOptions) map[string]bool {
if skip, result := fs.SkipWalk(fileName, isDir, isSymlink, done, ignore); skip {
if isDir && result != filepath.SkipDir {
folder := entity.NewFolder(entity.RootOriginals, fs.RelativeName(fileName, originalsPath), nil)
folder := entity.NewFolder(entity.RootDefault, fs.RelativeName(fileName, originalsPath), nil)
if err := folder.Create(); err == nil {
log.Infof("index: added folder /%s", folder.Path)

View file

@ -70,8 +70,9 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
fileBase := m.Base(ind.conf.Settings().Index.Group)
filePath := m.RelativePath(ind.originalsPath())
fileRoot := entity.RootOriginals
fileRoot := entity.RootDefault
fileName := m.RelativeName(ind.originalsPath())
quotedName := txt.Quote(m.RelativeName(ind.originalsPath()))
fileHash := ""
fileSize, fileModified := m.Stat()
fileChanged := true
@ -82,6 +83,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
"fileHash": fileHash,
"fileSize": fileSize,
"fileName": fileName,
"fileRoot": fileRoot,
"baseName": filepath.Base(fileName),
})
@ -97,15 +99,24 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
result.Status = IndexDuplicate
return result
}
if !fileExists && m.MetaData().HasInstanceID(){
fileQuery = ind.db.Unscoped().First(&file, "instance_id = ?", m.MetaData().InstanceID)
fileExists = fileQuery.Error == nil
}
}
if !fileExists {
photoQuery = ind.db.Unscoped().First(&photo, "photo_path = ? AND photo_name = ?", filePath, fileBase)
if photoQuery.Error != nil && m.HasTimeAndPlace() {
metaData, _ = m.MetaData()
if photoQuery.Error != nil && m.MetaData().HasTimeAndPlace() {
metaData = m.MetaData()
photoQuery = ind.db.Unscoped().First(&photo, "photo_lat = ? AND photo_lng = ? AND taken_at = ?", metaData.Lat, metaData.Lng, metaData.TakenAt)
}
if photoQuery.Error != nil && m.MetaData().HasDocumentID() {
photoQuery = ind.db.Unscoped().First(&photo, "document_id = ?", m.MetaData().DocumentID)
}
} else {
photoQuery = ind.db.Unscoped().First(&photo, "id = ?", file.PhotoID)
@ -160,7 +171,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
case m.IsJpeg():
// Color information
if p, err := m.Colors(ind.thumbPath()); err != nil {
log.Errorf("index: %s for %s", err.Error(), txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s for %s", err.Error(), quotedName)
} else {
file.FileMainColor = p.MainColor.Name()
file.FileColors = p.Colors.Hex()
@ -204,7 +215,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
photo.PhotoType = entity.TypeRaw
}
case m.IsVideo():
metaData, _ = m.MetaData()
metaData = m.MetaData()
file.FileCodec = metaData.Codec
file.FileWidth = metaData.Width
@ -258,7 +269,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
}
// read metadata from embedded Exif and JSON sidecar file (if exists)
if metaData, err := m.MetaData(); err == nil {
if metaData := m.MetaData(); metaData.Error == nil {
photo.SetTitle(metaData.Title, entity.SrcMeta)
photo.SetDescription(metaData.Description, entity.SrcMeta)
photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcMeta)
@ -288,10 +299,16 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
photo.CameraSerial = metaData.CameraSerial
}
if len(metaData.UniqueID) > 15 {
log.Debugf("index: found file uid %s for %s", txt.Quote(metaData.UniqueID), txt.Quote(m.RelativeName(ind.originalsPath())))
if metaData.HasDocumentID() && photo.DocumentID == "" {
log.Debugf("index: %s has document id %s", quotedName, txt.Quote(metaData.DocumentID))
file.FileUID = metaData.UniqueID
photo.DocumentID = metaData.DocumentID
}
if metaData.HasInstanceID() && file.InstanceID == "" {
log.Debugf("index: %s has instance id %s", quotedName, txt.Quote(metaData.InstanceID))
file.InstanceID = metaData.InstanceID
}
}
@ -329,7 +346,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
locKeywords, locLabels = photo.UpdateLocation(ind.conf.GeoCodingApi())
labels = append(labels, locLabels...)
} else {
log.Debugf("index: no coordinates in metadata for %s", txt.Quote(m.RelativeName(ind.originalsPath())))
log.Debugf("index: no coordinates in metadata for %s", quotedName)
photo.Location = &entity.UnknownLocation
photo.LocUID = entity.UnknownLocation.LocUID
@ -368,7 +385,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if photoExists {
if err := ind.db.Unscoped().Save(&photo).Error; err != nil {
log.Errorf("index: %s for %s", err.Error(), txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s for %s", err.Error(), quotedName)
result.Status = IndexFailed
result.Error = err
return result
@ -376,7 +393,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
} else {
if yamlName := fs.TypeYaml.FindSub(m.FileName(), fs.HiddenPath, ind.conf.Settings().Index.Group); yamlName != "" {
if err := photo.LoadFromYaml(yamlName); err != nil {
log.Errorf("index: %s (restore from yaml) for %s", err.Error(), txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s (restore from yaml) for %s", err.Error(), quotedName)
} else {
log.Infof("index: restored from %s", txt.Quote(fs.RelativeName(yamlName, ind.originalsPath())))
}
@ -423,7 +440,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
labels := photo.ClassifyLabels()
if err := photo.UpdateTitle(labels); err != nil {
log.Warnf("%s for %s", err.Error(), txt.Quote(m.RelativeName(ind.originalsPath())))
log.Warnf("%s for %s", err.Error(), quotedName)
}
w := txt.Keywords(photo.Details.Keywords)
@ -441,22 +458,22 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
photo.Details.Keywords = strings.Join(txt.UniqueWords(w), ", ")
if photo.Details.Keywords != "" {
log.Debugf("index: set keywords %s for %s", photo.Details.Keywords, txt.Quote(m.RelativeName(ind.originalsPath())))
log.Debugf("index: set keywords %s for %s", photo.Details.Keywords, quotedName)
} else {
log.Debugf("index: no keywords for %s", txt.Quote(m.RelativeName(ind.originalsPath())))
log.Debugf("index: no keywords for %s", quotedName)
}
photo.PhotoQuality = photo.QualityScore()
if err := ind.db.Unscoped().Save(&photo).Error; err != nil {
log.Errorf("index: %s for %s", err, txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s for %s", err, quotedName)
result.Status = IndexFailed
result.Error = err
return result
}
if err := photo.IndexKeywords(); err != nil {
log.Errorf("%s for %s", err, txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("%s for %s", err, quotedName)
}
} else {
if photo.PhotoQuality >= 0 {
@ -464,7 +481,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
}
if err := ind.db.Unscoped().Save(&photo).Error; err != nil {
log.Errorf("index: %s for %s", err, txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s for %s", err, quotedName)
result.Status = IndexFailed
result.Error = err
return result
@ -477,7 +494,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
file.UpdatedIn = int64(time.Since(start))
if err := ind.db.Unscoped().Save(&file).Error; err != nil {
log.Errorf("index: %s for %s", err, txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s for %s", err, quotedName)
result.Status = IndexFailed
result.Error = err
return result
@ -486,7 +503,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
file.CreatedIn = int64(time.Since(start))
if err := ind.db.Create(&file).Error; err != nil {
log.Errorf("index: %s for %s", err, txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s for %s", err, quotedName)
result.Status = IndexFailed
result.Error = err
return result
@ -501,7 +518,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if (photo.PhotoType == entity.TypeVideo || photo.PhotoType == entity.TypeLive) && file.FilePrimary {
if err := file.UpdateVideoInfos(); err != nil {
log.Errorf("index: %s for %s", err, txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s for %s", err, quotedName)
}
}
@ -515,7 +532,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
}
if err := query.SetDownloadFileID(downloadedAs, file.ID); err != nil {
log.Errorf("index: %s for %s", err, txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s for %s", err, quotedName)
}
// Write YAML sidecar file (optional).
@ -523,7 +540,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
yamlFile := photo.YamlFileName(ind.originalsPath(), ind.conf.SidecarHidden())
if err := photo.SaveAsYaml(yamlFile); err != nil {
log.Errorf("index: %s (update yaml) for %s", err.Error(), txt.Quote(m.RelativeName(ind.originalsPath())))
log.Errorf("index: %s (update yaml) for %s", err.Error(), quotedName)
} else {
log.Infof("index: updated yaml file %s", txt.Quote(fs.RelativeName(yamlFile, ind.originalsPath())))
}

View file

@ -12,11 +12,7 @@ func (m *MediaFile) Location() (*entity.Location, error) {
return m.location, nil
}
data, err := m.MetaData()
if err != nil {
return nil, err
}
data := m.MetaData()
if data.Lat == 0 && data.Lng == 0 {
return nil, errors.New("mediafile: no latitude and longitude in metadata")

View file

@ -92,10 +92,10 @@ func (m *MediaFile) TakenAt() (time.Time, string) {
m.takenAt = time.Now().UTC()
info, err := m.MetaData()
data := m.MetaData()
if err == nil && !info.TakenAt.IsZero() && info.TakenAt.Year() > 1000 {
m.takenAt = info.TakenAt.UTC()
if data.Error == nil && !data.TakenAt.IsZero() && data.TakenAt.Year() > 1000 {
m.takenAt = data.TakenAt.UTC()
m.takenAtSrc = entity.SrcMeta
log.Infof("mediafile: %s was taken at %s (%s)", filepath.Base(m.fileName), m.takenAt.String(), m.takenAtSrc)
@ -135,119 +135,67 @@ func (m *MediaFile) TakenAt() (time.Time, string) {
}
func (m *MediaFile) HasTimeAndPlace() bool {
exifData, err := m.MetaData()
data := m.MetaData()
if err != nil {
return false
}
result := !exifData.TakenAt.IsZero() && exifData.Lat != 0 && exifData.Lng != 0
result := !data.TakenAt.IsZero() && data.Lat != 0 && data.Lng != 0
return result
}
// CameraModel returns the camera model with which the media file was created.
func (m *MediaFile) CameraModel() string {
info, err := m.MetaData()
data := m.MetaData()
var result string
if err == nil {
result = info.CameraModel
}
return result
return data.CameraModel
}
// CameraMake returns the make of the camera with which the file was created.
func (m *MediaFile) CameraMake() string {
info, err := m.MetaData()
data := m.MetaData()
var result string
if err == nil {
result = info.CameraMake
}
return result
return data.CameraMake
}
// LensModel returns the lens model of a media file.
func (m *MediaFile) LensModel() string {
info, err := m.MetaData()
data := m.MetaData()
var result string
if err == nil {
result = info.LensModel
}
return result
return data.LensModel
}
// LensMake returns the make of the Lens.
func (m *MediaFile) LensMake() string {
info, err := m.MetaData()
data := m.MetaData()
var result string
if err == nil {
result = info.LensMake
}
return result
return data.LensMake
}
// FocalLength return the length of the focal for a file.
func (m *MediaFile) FocalLength() int {
info, err := m.MetaData()
data := m.MetaData()
var result int
if err == nil {
result = info.FocalLength
}
return result
return data.FocalLength
}
// FNumber returns the F number with which the media file was created.
func (m *MediaFile) FNumber() float32 {
info, err := m.MetaData()
data := m.MetaData()
var result float32
if err == nil {
result = info.FNumber
}
return result
return data.FNumber
}
// Iso returns the iso rating as int.
func (m *MediaFile) Iso() int {
info, err := m.MetaData()
data := m.MetaData()
var result int
if err == nil {
result = info.Iso
}
return result
return data.Iso
}
// Exposure returns the exposure time as string.
func (m *MediaFile) Exposure() string {
info, err := m.MetaData()
data := m.MetaData()
var result string
if err == nil {
result = info.Exposure
}
return result
return data.Exposure
}
// CanonicalName returns the canonical name of a media file.
@ -678,9 +626,9 @@ func (m *MediaFile) decodeDimensions() error {
var width, height int
data, err := m.MetaData()
data := m.MetaData()
if err == nil {
if data.Error == nil {
width = data.Width
height = data.Height
}
@ -757,7 +705,7 @@ func (m *MediaFile) AspectRatio() float32 {
// Orientation returns the orientation of a MediaFile.
func (m *MediaFile) Orientation() int {
if data, err := m.MetaData(); err == nil {
if data := m.MetaData(); data.Error == nil {
return data.Orientation
}

View file

@ -9,9 +9,9 @@ import (
)
// MetaData returns exif meta data of a media file.
func (m *MediaFile) MetaData() (result meta.Data, err error) {
func (m *MediaFile) MetaData() (result meta.Data) {
m.metaDataOnce.Do(func() {
err = m.metaData.Exif(m.FileName())
err := m.metaData.Exif(m.FileName())
if jsonFile := fs.TypeJson.FindSub(m.FileName(), fs.HiddenPath, false); jsonFile == "" {
log.Debugf("mediafile: no json sidecar file found for %s", txt.Quote(filepath.Base(m.FileName())))
@ -22,9 +22,10 @@ func (m *MediaFile) MetaData() (result meta.Data, err error) {
}
if err != nil {
m.metaData.Error = err
log.Debugf("mediafile: %s", err.Error())
}
})
return m.metaData, err
return m.metaData
}

View file

@ -17,36 +17,36 @@ func TestMediaFile_Exif_JPEG(t *testing.T) {
assert.Nil(t, err)
info, err := img.MetaData()
data := img.MetaData()
assert.Empty(t, err)
assert.IsType(t, meta.Data{}, info)
assert.IsType(t, meta.Data{}, data)
assert.Equal(t, "", info.UniqueID)
assert.Equal(t, "2013-11-26 13:53:55 +0000 UTC", info.TakenAt.String())
assert.Equal(t, "2013-11-26 15:53:55 +0000 UTC", info.TakenAtLocal.String())
assert.Equal(t, 1, info.Orientation)
assert.Equal(t, "Canon EOS 6D", info.CameraModel)
assert.Equal(t, "Canon", info.CameraMake)
assert.Equal(t, "EF70-200mm f/4L IS USM", info.LensModel)
assert.Equal(t, "", info.LensMake)
assert.Equal(t, "Africa/Johannesburg", info.TimeZone)
assert.Equal(t, "", info.Artist)
assert.Equal(t, 111, info.FocalLength)
assert.Equal(t, "1/640", info.Exposure)
assert.Equal(t, float32(6.644), info.Aperture)
assert.Equal(t, float32(10), info.FNumber)
assert.Equal(t, 200, info.Iso)
assert.Equal(t, float32(-33.45347), info.Lat)
assert.Equal(t, float32(25.764645), info.Lng)
assert.Equal(t, 190, info.Altitude)
assert.Equal(t, 497, info.Width)
assert.Equal(t, 331, info.Height)
assert.Equal(t, false, info.Flash)
assert.Equal(t, "", info.Description)
t.Logf("UTC: %s", info.TakenAt.String())
t.Logf("Local: %s", info.TakenAtLocal.String())
assert.Equal(t, "", data.DocumentID)
assert.Equal(t, "2013-11-26 13:53:55 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "2013-11-26 15:53:55 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, "Canon EOS 6D", data.CameraModel)
assert.Equal(t, "Canon", data.CameraMake)
assert.Equal(t, "EF70-200mm f/4L IS USM", data.LensModel)
assert.Equal(t, "", data.LensMake)
assert.Equal(t, "Africa/Johannesburg", data.TimeZone)
assert.Equal(t, "", data.Artist)
assert.Equal(t, 111, data.FocalLength)
assert.Equal(t, "1/640", data.Exposure)
assert.Equal(t, float32(6.644), data.Aperture)
assert.Equal(t, float32(10), data.FNumber)
assert.Equal(t, 200, data.Iso)
assert.Equal(t, float32(-33.45347), data.Lat)
assert.Equal(t, float32(25.764645), data.Lng)
assert.Equal(t, 190, data.Altitude)
assert.Equal(t, 497, data.Width)
assert.Equal(t, 331, data.Height)
assert.Equal(t, false, data.Flash)
assert.Equal(t, "", data.Description)
t.Logf("UTC: %s", data.TakenAt.String())
t.Logf("Local: %s", data.TakenAtLocal.String())
})
t.Run("fern_green.jpg", func(t *testing.T) {
@ -54,13 +54,13 @@ func TestMediaFile_Exif_JPEG(t *testing.T) {
assert.Nil(t, err)
info, err := img.MetaData()
info := img.MetaData()
assert.Empty(t, err)
assert.IsType(t, meta.Data{}, info)
assert.Equal(t, "", info.UniqueID)
assert.Equal(t, "", info.DocumentID)
assert.Equal(t, 1, info.Orientation)
assert.Equal(t, "Canon EOS 7D", info.CameraModel)
assert.Equal(t, "Canon", info.CameraMake)
@ -94,13 +94,13 @@ func TestMediaFile_Exif_DNG(t *testing.T) {
assert.Nil(t, err)
info, err := img.MetaData()
info := img.MetaData()
assert.Empty(t, err)
assert.IsType(t, meta.Data{}, info)
assert.Equal(t, "", info.UniqueID)
assert.Equal(t, "", info.DocumentID)
assert.Equal(t, "2019-06-06 07:29:51 +0000 UTC", info.TakenAt.String())
assert.Equal(t, "2019-06-06 07:29:51 +0000 UTC", info.TakenAtLocal.String())
assert.Equal(t, 1, info.Orientation)
@ -132,7 +132,7 @@ func TestMediaFile_Exif_HEIF(t *testing.T) {
assert.Nil(t, err)
info, err := img.MetaData()
info := img.MetaData()
assert.IsType(t, meta.Data{}, info)
@ -144,13 +144,13 @@ func TestMediaFile_Exif_HEIF(t *testing.T) {
assert.Nil(t, err)
jpegInfo, err := jpeg.MetaData()
jpegInfo := jpeg.MetaData()
assert.IsType(t, meta.Data{}, jpegInfo)
assert.Nil(t, err)
assert.Equal(t, "", jpegInfo.UniqueID)
assert.Equal(t, "", jpegInfo.DocumentID)
assert.Equal(t, "2018-09-10 03:16:13 +0000 UTC", jpegInfo.TakenAt.String())
assert.Equal(t, "2018-09-10 12:16:13 +0000 UTC", jpegInfo.TakenAtLocal.String())
assert.Equal(t, 6, jpegInfo.Orientation)

View file

@ -1,16 +1,8 @@
package photoprism
import (
"errors"
)
// TimeZone returns the time zone where the photo was taken.
func (m *MediaFile) TimeZone() (string, error) {
meta, err := m.MetaData()
func (m *MediaFile) TimeZone() string {
data := m.MetaData()
if err != nil {
return "UTC", errors.New("mediafile: unknown time zone, using UTC")
}
return meta.TimeZone, nil
return data.TimeZone
}

View file

@ -15,7 +15,7 @@ func TestMediaFile_TimeZone(t *testing.T) {
assert.Nil(t, err)
zone, err := img.TimeZone()
zone := img.TimeZone()
assert.Nil(t, err)
assert.Equal(t, "Africa/Johannesburg", zone)

View file

@ -9,7 +9,7 @@ import (
type Files []entity.File
// FilesByPath returns a slice of files in a given originals folder.
func FilesByPath(root, pathName string) (files Files, err error) {
func FilesByPath(rootName, pathName string) (files Files, err error) {
if strings.HasPrefix(pathName, "/") {
pathName = pathName[1:]
}
@ -18,7 +18,7 @@ func FilesByPath(root, pathName string) (files Files, err error) {
Table("files").Select("files.*").
Joins("JOIN photos ON photos.id = files.photo_id AND photos.deleted_at IS NULL").
Where("files.file_missing = 0").
Where("files.file_root = ? AND photos.photo_path = ?", root, pathName).
Where("files.file_root = ? AND photos.photo_path = ?", rootName, pathName).
Order("files.file_name").
Find(&files).Error

View file

@ -9,7 +9,7 @@ import (
func TestFilesByPath(t *testing.T) {
t.Run("files found", func(t *testing.T) {
files, err := FilesByPath(entity.RootOriginals, "2016/11")
files, err := FilesByPath(entity.RootDefault, "2016/11")
t.Logf("files: %+v", files)

View file

@ -10,7 +10,7 @@ import (
type Folders []entity.Folder
// FoldersByPath returns a slice of folders in a given directory incl sub directories in recursive mode.
func FoldersByPath(root, rootPath, path string, recursive bool) (folders Folders, err error) {
func FoldersByPath(rootName, rootPath, path string, recursive bool) (folders Folders, err error) {
dirs, err := fs.Dirs(filepath.Join(rootPath, path), recursive)
if err != nil {
@ -20,7 +20,7 @@ func FoldersByPath(root, rootPath, path string, recursive bool) (folders Folders
folders = make(Folders, len(dirs))
for i, dir := range dirs {
folder := entity.NewFolder(root, filepath.Join(path, dir), nil)
folder := entity.NewFolder(rootName, filepath.Join(path, dir), nil)
if f := entity.FirstOrCreateFolder(&folder); f != nil {
folders[i] = *f

View file

@ -9,7 +9,7 @@ import (
func TestFoldersByPath(t *testing.T) {
t.Run("root", func(t *testing.T) {
folders, err := FoldersByPath(entity.RootOriginals, "testdata", "", false)
folders, err := FoldersByPath(entity.RootDefault, "testdata", "", false)
t.Logf("folders: %+v", folders)
@ -21,7 +21,7 @@ func TestFoldersByPath(t *testing.T) {
})
t.Run("subdirectory", func(t *testing.T) {
folders, err := FoldersByPath(entity.RootOriginals, "testdata", "directory", false)
folders, err := FoldersByPath(entity.RootDefault, "testdata", "directory", false)
t.Logf("folders: %+v", folders)

View file

@ -18,6 +18,7 @@ func PhotoByID(photoID uint64) (photo entity.Photo, err error) {
Preload("Camera").
Preload("Lens").
Preload("Details").
Preload("Place").
Preload("Location").
Preload("Location.Place").
First(&photo).Error; err != nil {
@ -38,6 +39,7 @@ func PhotoByUID(photoUID string) (photo entity.Photo, err error) {
Preload("Camera").
Preload("Lens").
Preload("Details").
Preload("Place").
Preload("Location").
Preload("Location.Place").
First(&photo).Error; err != nil {
@ -58,6 +60,7 @@ func PhotoPreloadByUID(photoUID string) (photo entity.Photo, err error) {
Preload("Camera").
Preload("Lens").
Preload("Details").
Preload("Place").
Preload("Location").
Preload("Location.Place").
First(&photo).Error; err != nil {
@ -101,6 +104,7 @@ func PhotosMaintenance(limit int, offset int) (entities Photos, err error) {
Preload("Camera").
Preload("Lens").
Preload("Details").
Preload("Place").
Preload("Location").
Preload("Location.Place").
Where("maintained_at IS NULL OR maintained_at < ?", time.Now().Add(-1*time.Hour*24*7)).

View file

@ -16,7 +16,8 @@ type Photos []entity.Photo
// PhotoResult contains found photos and their main file plus other meta data.
type PhotoResult struct {
ID uint `json:"ID"`
ID uint `json:"-"`
DocumentID string `json:"DocumentID,omitempty"`
PhotoUID string `json:"UID"`
PhotoType string `json:"Type"`
TakenAt time.Time `json:"TakenAt"`

View file

@ -1,25 +0,0 @@
package rnd
import (
"strconv"
"time"
)
// PPID returns a unique id with prefix as string.
func PPID(prefix byte) string {
result := make([]byte, 0, 16)
result = append(result, prefix)
result = append(result, strconv.FormatInt(time.Now().UTC().Unix(), 36)[0:6]...)
result = append(result, Token(9)...)
return string(result)
}
// IsPPID returns true if the id seems to be a PhotoPrism unique id.
func IsPPID(id string, prefix byte) bool {
if len(id) != 16 {
return false
}
return id[0] == prefix
}

View file

@ -1,32 +0,0 @@
package rnd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPPID(t *testing.T) {
for n := 0; n < 5; n++ {
uid := PPID('x')
t.Logf("id: %s", uid)
assert.Equal(t, len(uid), 16)
}
}
func BenchmarkPPID(b *testing.B) {
for n := 0; n < b.N; n++ {
PPID('x')
}
}
func TestIsPPID(t *testing.T) {
prefix := byte('x')
for n := 0; n < 10; n++ {
id := PPID(prefix)
assert.True(t, IsPPID(id, prefix))
}
assert.True(t, IsPPID("lt9k3pw1wowuy3c2", 'l'))
}

40
pkg/rnd/uid.go Normal file
View file

@ -0,0 +1,40 @@
package rnd
import (
"strconv"
"time"
)
// PPID returns a unique id with prefix as string.
func PPID(prefix byte) string {
result := make([]byte, 0, 16)
result = append(result, prefix)
result = append(result, strconv.FormatInt(time.Now().UTC().Unix(), 36)[0:6]...)
result = append(result, Token(9)...)
return string(result)
}
// IsPPID returns true if string is a unique id as generated by PhotoPrism.
func IsPPID(s string, prefix byte) bool {
if len(s) != 16 {
return false
}
return s[0] == prefix
}
// IsUID returns true if string is a seemingly unique id.
func IsUID(s string, prefix byte) bool {
// Regular UUID.
if len(s) == 36 {
return true
}
// Not a known UID format.
if len(s) != 16 {
return false
}
return IsPPID(s, prefix)
}

46
pkg/rnd/uid_test.go Normal file
View file

@ -0,0 +1,46 @@
package rnd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPPID(t *testing.T) {
for n := 0; n < 5; n++ {
uid := PPID('x')
t.Logf("id: %s", uid)
assert.Equal(t, len(uid), 16)
}
}
func BenchmarkPPID(b *testing.B) {
for n := 0; n < b.N; n++ {
PPID('x')
}
}
func TestIsPPID(t *testing.T) {
prefix := byte('x')
for n := 0; n < 10; n++ {
id := PPID(prefix)
assert.True(t, IsPPID(id, prefix))
}
assert.True(t, IsPPID("lt9k3pw1wowuy3c2", 'l'))
}
func TestIsUID(t *testing.T) {
assert.True(t, IsUID("lt9k3pw1wowuy3c2", 'l'))
// xmp.iid:dafbfeb8-a129-4e7c-9cf0-e7996a701cdb
assert.True(t, IsUID("dafbfeb8-a129-4e7c-9cf0-e7996a701cdb", 'l'))
assert.True(t, IsUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8", 'l'))
assert.True(t, IsUID("55785BAC-9A4B-4747-B090-EE123FFEE437", 'l'))
assert.True(t, IsUID("550e8400-e29b-11d4-a716-446655440000", 'l'))
assert.False(t, IsUID("4B1FEF2D1CF4A5BE38B263E0637EDEAD", 'l'))
assert.False(t, IsUID("123", '1'))
assert.False(t, IsUID("_", '_'))
assert.False(t, IsUID("", '_'))
}