diff --git a/frontend/src/common/util.js b/frontend/src/common/util.js
index b07074fea..980bbd6cf 100644
--- a/frontend/src/common/util.js
+++ b/frontend/src/common/util.js
@@ -1,4 +1,53 @@
+const Nanosecond = 1
+const Microsecond = 1000 * Nanosecond
+const Millisecond = 1000 * Microsecond
+const Second = 1000 * Millisecond
+const Minute = 60 * Second
+const Hour = 60 * Minute
+
export default class Util {
+ static duration(d) {
+ let u = d;
+
+ let neg = d < 0;
+
+ if (neg) {
+ u = -u
+ }
+
+ if (u < Second) {
+ // Special case: if duration is smaller than a second,
+ // use smaller units, like 1.2ms
+ if (!u) {
+ return "0s";
+ }
+
+ if (u < Microsecond) {
+ return u + "ns";
+ }
+
+ if (u < Millisecond) {
+ return Math.round(u / Microsecond) + "µs";
+ }
+
+ return Math.round(u / Millisecond) + "ms";
+ }
+
+ let result = []
+
+ let h = Math.floor(u / Hour)
+ let min = Math.floor(u / Minute)%60
+ let sec = Math.ceil(u / Second)%60
+
+ result.push(h.toString().padStart(2, '0'))
+ result.push(min.toString().padStart(2, '0'))
+ result.push(sec.toString().padStart(2, '0'))
+
+ // return `${h}h${min}m${sec}s`
+
+ return result.join(":");
+ }
+
static arabicToRoman(number) {
let roman = "";
const romanNumList = {
@@ -34,7 +83,7 @@ export default class Util {
return roman;
}
- static truncate (str, length, ending) {
+ static truncate(str, length, ending) {
if (length == null) {
length = 100;
}
diff --git a/frontend/src/component/p-photo-cards.vue b/frontend/src/component/p-photo-cards.vue
index 902dbfc1b..c5f3b344c 100644
--- a/frontend/src/component/p-photo-cards.vue
+++ b/frontend/src/component/p-photo-cards.vue
@@ -94,7 +94,11 @@
{{ photo.getDateString() }}
-
-
+
diff --git a/frontend/src/dialog/photo/files.vue b/frontend/src/dialog/photo/files.vue
index beb4766b2..7497b7688 100644
--- a/frontend/src/dialog/photo/files.vue
+++ b/frontend/src/dialog/photo/files.vue
@@ -79,9 +79,9 @@
return "";
}
- const kb = Number.parseFloat(file.FileSize) / 1048576;
+ const size = Number.parseFloat(file.FileSize) / 1048576;
- return kb.toFixed(1) + " MB";
+ return size.toFixed(1) + " MB";
},
fileType(file) {
if (file.FileVideo) {
diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js
index 4dd132e84..26be74e14 100644
--- a/frontend/src/model/photo.js
+++ b/frontend/src/model/photo.js
@@ -1,15 +1,9 @@
import RestModel from "model/rest";
import Api from "common/api";
import {DateTime} from "luxon";
+import Util from "common/util";
-const SrcAuto = "";
const SrcManual = "manual";
-const SrcLocation = "location";
-const SrcImage = "image";
-const SrcExif = "exif";
-const SrcXmp = "xmp";
-const SrcYml = "yml";
-const SrcJson = "json";
class Photo extends RestModel {
getDefaults() {
@@ -245,6 +239,35 @@ class Photo extends RestModel {
return "Unknown";
}
+ getVideoInfo() {
+ let result = [];
+ let file = this.videoFile();
+
+ if(!file) {
+ file = this.mainFile();
+ }
+
+ if(!file) {
+ return "Video";
+ }
+
+ if (file.FileLength > 0) {
+ result.push(Util.duration(file.FileLength))
+ }
+
+ if (file.FileWidth && file.FileHeight) {
+ result.push(file.FileWidth + " × " + file.FileHeight);
+ }
+
+ if(file.FileSize) {
+ const size = Number.parseFloat(file.FileSize) / 1048576;
+
+ result.push(size.toFixed(1) + " MB");
+ }
+
+ return result.join(", ");
+ }
+
getCamera() {
if (this.Camera) {
return this.Camera.CameraMake + " " + this.Camera.CameraModel;
diff --git a/internal/classify/const.go b/internal/classify/const.go
new file mode 100644
index 000000000..c80cd543a
--- /dev/null
+++ b/internal/classify/const.go
@@ -0,0 +1,9 @@
+package classify
+
+const (
+ // data sources
+ SrcAuto = ""
+ SrcManual = "manual"
+ SrcLocation = "location"
+ SrcImage = "image"
+)
diff --git a/internal/classify/label.go b/internal/classify/label.go
index 24cb6c9bd..a3780cf33 100644
--- a/internal/classify/label.go
+++ b/internal/classify/label.go
@@ -25,7 +25,7 @@ func LocationLabel(name string, uncertainty int, priority int) Label {
name = name[:index]
}
- label := Label{Name: name, Source: "location", Uncertainty: uncertainty, Priority: priority}
+ label := Label{Name: name, Source: SrcLocation, Uncertainty: uncertainty, Priority: priority}
return label
}
diff --git a/internal/classify/tensorflow.go b/internal/classify/tensorflow.go
index 979b0b303..9ab13f4f8 100644
--- a/internal/classify/tensorflow.go
+++ b/internal/classify/tensorflow.go
@@ -190,7 +190,7 @@ func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
uncertainty := 100 - int(math.Round(float64(p*100)))
- result = append(result, Label{Name: labelText, Source: "image", Uncertainty: uncertainty, Priority: rule.Priority, Categories: rule.Categories})
+ result = append(result, Label{Name: labelText, Source: SrcImage, Uncertainty: uncertainty, Priority: rule.Priority, Categories: rule.Categories})
}
// Sort by probability
diff --git a/internal/config/flags.go b/internal/config/flags.go
index af111bcca..df51016f2 100644
--- a/internal/config/flags.go
+++ b/internal/config/flags.go
@@ -174,7 +174,7 @@ var GlobalFlags = []cli.Flag{
EnvVar: "PHOTOPRISM_EXIFTOOL_BIN",
},
cli.BoolFlag{
- Name: "sidecar-json",
+ Name: "sidecar-json, j",
Usage: "sync metadata with json sidecar files as used by exiftool",
EnvVar: "PHOTOPRISM_SIDECAR_JSON",
},
diff --git a/internal/entity/const.go b/internal/entity/const.go
index 2742d7878..e3dd53502 100644
--- a/internal/entity/const.go
+++ b/internal/entity/const.go
@@ -1,15 +1,15 @@
package entity
+import "github.com/photoprism/photoprism/internal/classify"
+
const (
// data sources
SrcAuto = ""
SrcManual = "manual"
- SrcLocation = "location"
- SrcImage = "image"
- SrcExif = "exif"
+ SrcMeta = "meta"
SrcXmp = "xmp"
- SrcYml = "yml"
- SrcJson = "json"
+ SrcLocation = classify.SrcLocation
+ SrcImage = classify.SrcImage
// sort orders
SortOrderRelevance = "relevance"
diff --git a/internal/meta/data.go b/internal/meta/data.go
index 15a26ef8e..f67d8294f 100644
--- a/internal/meta/data.go
+++ b/internal/meta/data.go
@@ -1,6 +1,7 @@
package meta
import (
+ "math"
"time"
)
@@ -9,10 +10,10 @@ type Data struct {
UniqueID string `meta:"ImageUniqueID"`
TakenAt time.Time `meta:"DateTimeOriginal,CreateDate,MediaCreateDate"`
TakenAtLocal time.Time `meta:"DateTimeOriginal,CreateDate,MediaCreateDate"`
- Duration time.Duration `meta:"Duration,MediaDuration"`
+ Duration time.Duration `meta:"Duration,MediaDuration,TrackDuration"`
TimeZone string `meta:"-"`
Title string `meta:"Title"`
- Subject string `meta:"Subject"`
+ Subject string `meta:"Subject,PersonInImage"`
Keywords string `meta:"Keywords"`
Comment string `meta:"-"`
Artist string `meta:"Artist,Creator"`
@@ -31,11 +32,37 @@ type Data struct {
FNumber float32 `meta:"FNumber"`
Iso int `meta:"ISO"`
GPSPosition string `meta:"GPSPosition"`
- Lat float32 `meta:"-"` // TODO
- Lng float32 `meta:"-"` // TODO
- Altitude int `meta:"-"`
+ GPSLatitude string `meta:"GPSLatitude"`
+ GPSLongitude string `meta:"GPSLongitude"`
+ Lat float32 `meta:"-"`
+ Lng float32 `meta:"-"`
+ Altitude int `meta:"GlobalAltitude"`
Width int `meta:"ImageWidth"`
Height int `meta:"ImageHeight"`
Orientation int `meta:"-"`
All map[string]string
}
+
+// AspectRatio returns the aspect ratio based on width and height.
+func (data Data) AspectRatio() float32 {
+ width := float64(data.Width)
+ height := float64(data.Height)
+
+ if width <= 0 || height <= 0 {
+ return 0
+ }
+
+ aspectRatio := float32(width / height)
+
+ return aspectRatio
+}
+
+// Portrait returns true if it's a portrait picture or video based on width and height.
+func (data Data) Portrait() bool {
+ return data.Width < data.Height
+}
+
+// Megapixels returns the resolution in megapixels.
+func (data Data) Megapixels() int {
+ return int(math.Round(float64(data.Width*data.Height) / 1000000))
+}
diff --git a/internal/meta/duration.go b/internal/meta/duration.go
new file mode 100644
index 000000000..71202405e
--- /dev/null
+++ b/internal/meta/duration.go
@@ -0,0 +1,35 @@
+package meta
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var DurationSecondsRegexp = regexp.MustCompile("[0-9\\.]+")
+
+// StringToDuration converts a metadata string to a valid duration.
+func StringToDuration(s string) (d time.Duration) {
+ if s == "" {
+ return d
+ }
+
+ s = strings.TrimSpace(s)
+ sec := DurationSecondsRegexp.FindAllString(s, -1)
+
+ if len(sec) == 1 {
+ secFloat, _ := strconv.ParseFloat(sec[0], 64)
+ d = time.Duration(secFloat) * time.Second
+ } else if n := strings.Split(s, ":"); len(n) == 3 {
+ h, _ := strconv.Atoi(n[0])
+ m, _ := strconv.Atoi(n[1])
+ s, _ := strconv.Atoi(n[2])
+
+ d = time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second
+ } else if pd, err := time.ParseDuration(s); err != nil {
+ d = pd
+ }
+
+ return d
+}
diff --git a/internal/meta/duration_test.go b/internal/meta/duration_test.go
new file mode 100644
index 000000000..f99035e01
--- /dev/null
+++ b/internal/meta/duration_test.go
@@ -0,0 +1,50 @@
+
+package meta
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestStringToDuration(t *testing.T) {
+ t.Run("empty", func(t *testing.T) {
+ d := StringToDuration("")
+ assert.Equal(t, "0s", d.String())
+ })
+
+ t.Run("0", func(t *testing.T) {
+ d := StringToDuration("0")
+ assert.Equal(t, "0s", d.String())
+ })
+
+ t.Run("2.41 s", func(t *testing.T) {
+ d := StringToDuration("2.41 s")
+ assert.Equal(t, "2s", d.String())
+ })
+
+ t.Run("0.41 s", func(t *testing.T) {
+ d := StringToDuration("0.41 s")
+ assert.Equal(t, "0s", d.String())
+ })
+
+ t.Run("41 s", func(t *testing.T) {
+ d := StringToDuration("41 s")
+ assert.Equal(t, "41s", d.String())
+ })
+
+ t.Run("0:0:1", func(t *testing.T) {
+ d := StringToDuration("0:0:1")
+ assert.Equal(t, "1s", d.String())
+ })
+
+ t.Run("0:04:25", func(t *testing.T) {
+ d := StringToDuration("0:04:25")
+ assert.Equal(t, "4m25s", d.String())
+ })
+
+ t.Run("0001:04:25", func(t *testing.T) {
+ d := StringToDuration("0001:04:25")
+ assert.Equal(t, "1h4m25s", d.String())
+ })
+}
diff --git a/internal/meta/gps.go b/internal/meta/gps.go
new file mode 100644
index 000000000..b506f0350
--- /dev/null
+++ b/internal/meta/gps.go
@@ -0,0 +1,80 @@
+package meta
+
+import (
+ "regexp"
+ "strconv"
+
+ "github.com/dsoprea/go-exif/v2"
+)
+
+// var GpsCoordsRegexp = regexp.MustCompile("(-?\\d+(\\.\\d+)?),\\s*(-?\\d+(\\.\\d+)?)")
+var GpsCoordsRegexp = regexp.MustCompile("[0-9\\.]+")
+var GpsRefRegexp = regexp.MustCompile("[NSEW]+")
+
+// GpsToLatLng returns the GPS latitude and longitude as float point number.
+func GpsToLatLng(s string) (lat, lng float32) {
+ if s == "" {
+ return 0, 0
+ }
+
+ co := GpsCoordsRegexp.FindAllString(s, -1)
+ re := GpsRefRegexp.FindAllString(s, -1)
+
+ if len(co) != 6 || len(re) != 2 {
+ return 0, 0
+ }
+
+ latDeg := exif.GpsDegrees{
+ Orientation: re[0][0],
+ Degrees: GpsCoord(co[0]),
+ Minutes: GpsCoord(co[1]),
+ Seconds: GpsCoord(co[2]),
+ }
+
+ lngDeg := exif.GpsDegrees{
+ Orientation: re[1][0],
+ Degrees: GpsCoord(co[3]),
+ Minutes: GpsCoord(co[4]),
+ Seconds: GpsCoord(co[5]),
+ }
+
+ return float32(latDeg.Decimal()), float32(lngDeg.Decimal())
+}
+
+// GpsToDecimal returns the GPS latitude or longitude as decimal float point number.
+func GpsToDecimal(s string) float32 {
+ if s == "" {
+ return 0
+ }
+
+ co := GpsCoordsRegexp.FindAllString(s, -1)
+ re := GpsRefRegexp.FindAllString(s, -1)
+
+ if len(co) != 3 || len(re) != 1 {
+ return 0
+ }
+
+ latDeg := exif.GpsDegrees{
+ Orientation: re[0][0],
+ Degrees: GpsCoord(co[0]),
+ Minutes: GpsCoord(co[1]),
+ Seconds: GpsCoord(co[2]),
+ }
+
+ return float32(latDeg.Decimal())
+}
+
+// GpsToLng returns a single GPS coordinate value as float point number (degree, minute or second).
+func GpsCoord(s string) float64 {
+ if s == "" {
+ return 0
+ }
+
+ result, err := strconv.ParseFloat(s, 64)
+
+ if err != nil {
+ log.Debugf("meta: %s", err)
+ }
+
+ return result
+}
diff --git a/internal/meta/gps_test.go b/internal/meta/gps_test.go
new file mode 100644
index 000000000..d2c4f134d
--- /dev/null
+++ b/internal/meta/gps_test.go
@@ -0,0 +1,34 @@
+package meta
+
+import "testing"
+
+func TestGpsToLat(t *testing.T) {
+ lat := GpsToDecimal("51 deg 15' 17.47\" N")
+ exp := float32(51.254852)
+
+ if lat-exp > 0 {
+ t.Fatalf("lat is %f, should be %f", lat, exp)
+ }
+}
+
+func TestGpsToLng(t *testing.T) {
+ lng := GpsToDecimal("7 deg 23' 22.09\" E")
+ exp := float32(7.389470)
+
+ if lng-exp > 0 {
+ t.Fatalf("lng is %f, should be %f", lng, exp)
+ }
+}
+
+func TestGpsToLatLng(t *testing.T) {
+ lat, lng := GpsToLatLng("51 deg 15' 17.47\" N, 7 deg 23' 22.09\" E")
+ expLat, expLng := float32(51.254852), float32(7.389470)
+
+ if lat-expLat > 0 {
+ t.Fatalf("lat is %f, should be %f", lat, expLat)
+ }
+
+ if lng-expLng > 0 {
+ t.Fatalf("lng is %f, should be %f", lng, expLng)
+ }
+}
diff --git a/internal/meta/json.go b/internal/meta/json.go
index c11872f00..7c247bc91 100644
--- a/internal/meta/json.go
+++ b/internal/meta/json.go
@@ -5,12 +5,12 @@ import (
"io/ioutil"
"path/filepath"
"reflect"
- "strconv"
"strings"
"time"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/tidwall/gjson"
+ "gopkg.in/ugjka/go-tz.v2/tz"
)
// JSON parses a json sidecar file (as used by Exiftool) and returns a Data struct.
@@ -81,18 +81,10 @@ func (data *Data) JSON(filename string) (err error) {
switch t := fieldValue.Interface().(type) {
case time.Time:
if tv, err := time.Parse("2006:01:02 15:04:05", strings.TrimSpace(jsonValue.String())); err == nil {
- fieldValue.Set(reflect.ValueOf(tv))
+ fieldValue.Set(reflect.ValueOf(tv.Round(time.Second).UTC()))
}
case time.Duration:
- if n := strings.Split(strings.TrimSpace(jsonValue.String()), ":"); len(n) == 3 {
- h, _ := strconv.Atoi(n[0])
- m, _ := strconv.Atoi(n[1])
- s, _ := strconv.Atoi(n[2])
-
- dv := time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second
-
- fieldValue.Set(reflect.ValueOf(dv))
- }
+ fieldValue.Set(reflect.ValueOf(StringToDuration(jsonValue.String())))
case int, int64:
fieldValue.SetInt(jsonValue.Int())
case float32, float64:
@@ -109,5 +101,35 @@ func (data *Data) JSON(filename string) (err error) {
}
}
+ // Calculate latitude and longitude if exists.
+ if data.GPSPosition != "" {
+ data.Lat, data.Lng = GpsToLatLng(data.GPSPosition)
+ } else if data.GPSLatitude != "" && data.GPSLongitude != "" {
+ data.Lat = GpsToDecimal(data.GPSLatitude)
+ data.Lng = GpsToDecimal(data.GPSLongitude)
+ }
+
+ // Set time zone and calculate UTC time.
+ if data.Lat != 0 && data.Lng != 0 {
+ zones, err := tz.GetZone(tz.Point{
+ Lat: float64(data.Lat),
+ Lon: float64(data.Lng),
+ })
+
+ if err == nil && len(zones) > 0 {
+ data.TimeZone = zones[0]
+ }
+
+ if !data.TakenAtLocal.IsZero() {
+ if loc, err := time.LoadLocation(data.TimeZone); err != nil {
+ log.Warnf("meta: unknown time zone %s", data.TimeZone)
+ } else if tl, err := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); err == nil {
+ data.TakenAt = tl.Round(time.Second).UTC()
+ } else {
+ log.Errorf("meta: %s", err.Error()) // this should never happen
+ }
+ }
+ }
+
return nil
}
diff --git a/internal/meta/json_test.go b/internal/meta/json_test.go
index 079e2a5e0..8e5fc4283 100644
--- a/internal/meta/json_test.go
+++ b/internal/meta/json_test.go
@@ -26,6 +26,25 @@ func TestJSON(t *testing.T) {
assert.Equal(t, "", data.LensModel)
})
+ t.Run("gopher-video.json", func(t *testing.T) {
+ data, err := JSON("testdata/gopher-video.json")
+
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // t.Logf("DATA: %+v", data)
+
+ assert.Equal(t, "2s", data.Duration.String())
+ assert.Equal(t, "2020-05-11 14:18:35 +0000 UTC", data.TakenAtLocal.String())
+ assert.Equal(t, 270, data.Width)
+ assert.Equal(t, 480, data.Height)
+ assert.Equal(t, "", data.Copyright)
+ assert.Equal(t, "", data.CameraMake)
+ assert.Equal(t, "", data.CameraModel)
+ assert.Equal(t, "", data.LensModel)
+ })
+
t.Run("photoshop.json", func(t *testing.T) {
data, err := JSON("testdata/photoshop.json")
@@ -35,6 +54,12 @@ func TestJSON(t *testing.T) {
// t.Logf("DATA: %+v", data)
+ assert.Equal(t, "0s", data.Duration.String())
+ assert.Equal(t, float32(52.45969), data.Lat)
+ assert.Equal(t, float32(13.321831), data.Lng)
+ assert.Equal(t, "2020-01-01 16:28:23 +0000 UTC", data.TakenAt.String())
+ assert.Equal(t, "2020-01-01 17:28:23 +0000 UTC", data.TakenAtLocal.String())
+ assert.Equal(t, "Europe/Berlin", data.TimeZone)
assert.Equal(t, "Night Shift / Berlin / 2020", data.Title)
assert.Equal(t, "Michael Mayer", data.Artist)
assert.Equal(t, "Example file for development", data.Description)
diff --git a/internal/meta/testdata/gopher-video.json b/internal/meta/testdata/gopher-video.json
new file mode 100644
index 000000000..7c6247ba6
--- /dev/null
+++ b/internal/meta/testdata/gopher-video.json
@@ -0,0 +1,69 @@
+[{
+ "SourceFile": "/go/src/github.com/photoprism/photoprism/assets/photos/originals/video/gopher-video.mp4",
+ "ExifToolVersion": 10.80,
+ "FileName": "gopher-video.mp4",
+ "Directory": "/go/src/github.com/photoprism/photoprism/assets/photos/originals/video",
+ "FileSize": "331 kB",
+ "FileModifyDate": "2020:05:11 14:19:45+00:00",
+ "FileAccessDate": "2020:05:11 15:40:29+00:00",
+ "FileInodeChangeDate": "2020:05:11 15:40:29+00:00",
+ "FilePermissions": "rw-r--r--",
+ "FileType": "MP4",
+ "FileTypeExtension": "mp4",
+ "MIMEType": "video/mp4",
+ "MajorBrand": "MP4 Base Media v1 [IS0 14496-12:2003]",
+ "MinorVersion": "0.2.0",
+ "CompatibleBrands": ["isom","iso2","avc1","mp41"],
+ "MovieDataSize": 336136,
+ "MovieDataOffset": 40,
+ "MovieHeaderVersion": 0,
+ "CreateDate": "2020:05:11 14:18:35",
+ "ModifyDate": "2020:05:11 14:18:35",
+ "TimeScale": 6000,
+ "Duration": "2.41 s",
+ "PreferredRate": 1,
+ "PreferredVolume": "100.00%",
+ "PreviewTime": "0 s",
+ "PreviewDuration": "0 s",
+ "PosterTime": "0 s",
+ "SelectionTime": "0 s",
+ "SelectionDuration": "0 s",
+ "CurrentTime": "0 s",
+ "NextTrackID": 3,
+ "TrackHeaderVersion": 0,
+ "TrackCreateDate": "2020:05:11 14:18:33",
+ "TrackModifyDate": "2020:05:11 14:18:35",
+ "TrackID": 1,
+ "TrackDuration": "2.41 s",
+ "TrackLayer": 0,
+ "TrackVolume": "100.00%",
+ "Balance": 0,
+ "AudioFormat": "mp4a",
+ "AudioChannels": 2,
+ "AudioBitsPerSample": 16,
+ "AudioSampleRate": 48000,
+ "MatrixStructure": "1 0 0 0 1 0 0 0 1",
+ "ImageWidth": 270,
+ "ImageHeight": 480,
+ "MediaHeaderVersion": 0,
+ "MediaCreateDate": "2020:05:11 14:18:33",
+ "MediaModifyDate": "2020:05:11 14:18:35",
+ "MediaTimeScale": 90000,
+ "MediaDuration": "2.41 s",
+ "MediaLanguageCode": "eng",
+ "HandlerType": "Video Track",
+ "HandlerDescription": "VideoHandle",
+ "GraphicsMode": "srcCopy",
+ "OpColor": "0 0 0",
+ "CompressorID": "avc1",
+ "SourceImageWidth": 270,
+ "SourceImageHeight": 480,
+ "XResolution": 72,
+ "YResolution": 72,
+ "BitDepth": 24,
+ "VideoFrameRate": 28.216,
+ "AvgBitrate": "1.12 Mbps",
+ "ImageSize": "270x480",
+ "Megapixels": 0.130,
+ "Rotation": 0
+}]
diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go
index 61e319852..a1fd6f612 100644
--- a/internal/photoprism/index_mediafile.go
+++ b/internal/photoprism/index_mediafile.go
@@ -137,6 +137,17 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if m.IsVideo() {
photo.PhotoVideo = true
+ metaData, _ = m.MetaData()
+
+ file.FileWidth = metaData.Width
+ file.FileHeight = metaData.Height
+ file.FileLength = metaData.Duration
+ file.FileAspectRatio = metaData.AspectRatio()
+ file.FilePortrait = metaData.Portrait()
+
+ if res := metaData.Megapixels(); res > photo.PhotoResolution {
+ photo.PhotoResolution = res
+ }
}
if !file.FilePrimary {
@@ -176,12 +187,12 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
}
if fileChanged || o.Rescan {
- // Read UpdateExif data
+ // read metadata from embedded Exif and JSON sidecar file (if exists)
if metaData, err := m.MetaData(); err == nil {
- photo.SetTitle(metaData.Title, entity.SrcExif)
- photo.SetDescription(metaData.Description, entity.SrcExif)
- photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcExif)
- photo.SetCoordinates(metaData.Lat, metaData.Lng, metaData.Altitude, entity.SrcExif)
+ photo.SetTitle(metaData.Title, entity.SrcMeta)
+ photo.SetDescription(metaData.Description, entity.SrcMeta)
+ photo.SetTakenAt(metaData.TakenAt, metaData.TakenAtLocal, metaData.TimeZone, entity.SrcMeta)
+ photo.SetCoordinates(metaData.Lat, metaData.Lng, metaData.Altitude, entity.SrcMeta)
if photo.Description.NoNotes() {
photo.Description.PhotoNotes = metaData.Comment
diff --git a/internal/photoprism/metadata.go b/internal/photoprism/metadata.go
index 82b7de468..ab79655ce 100644
--- a/internal/photoprism/metadata.go
+++ b/internal/photoprism/metadata.go
@@ -11,13 +11,15 @@ import (
// MetaData returns exif meta data of a media file.
func (m *MediaFile) MetaData() (result meta.Data, err error) {
m.metaDataOnce.Do(func() {
+ err = m.metaData.Exif(m.FileName())
+
if jsonFile := fs.TypeJson.Find(m.FileName(), false); jsonFile == "" {
log.Debugf("mediafile: no json sidecar file found for %s", txt.Quote(filepath.Base(m.FileName())))
- } else if err := m.metaData.JSON(jsonFile); err != nil {
- log.Warn(err)
+ } else if jsonErr := m.metaData.JSON(jsonFile); jsonErr != nil {
+ log.Warn(jsonErr)
+ } else {
+ err = nil
}
-
- err = m.metaData.Exif(m.FileName())
})
return m.metaData, err
diff --git a/internal/query/photos.go b/internal/query/photos.go
index 4122380b7..ffb9d31d8 100644
--- a/internal/query/photos.go
+++ b/internal/query/photos.go
@@ -29,7 +29,7 @@ func Photos(f form.PhotoSearch) (results PhotosResults, count int, err error) {
files.id AS file_id, files.file_uuid, files.file_primary, files.file_missing, files.file_name, files.file_hash,
files.file_type, files.file_mime, files.file_width, files.file_height, files.file_aspect_ratio,
files.file_orientation, files.file_main_color, files.file_colors, files.file_luminance, files.file_chroma,
- files.file_diff, files.file_video, files.file_length,
+ files.file_diff, files.file_video, files.file_length, files.file_size,
cameras.camera_make, cameras.camera_model,
lenses.lens_make, lenses.lens_model,
places.loc_label, places.loc_city, places.loc_state, places.loc_country`).
diff --git a/internal/query/photos_results.go b/internal/query/photos_results.go
index 4d534f7f0..1ed55c9c8 100644
--- a/internal/query/photos_results.go
+++ b/internal/query/photos_results.go
@@ -74,6 +74,7 @@ type PhotosResult struct {
FileMime string
FileWidth int
FileHeight int
+ FileSize int64
FileOrientation int
FileAspectRatio float32
FileColors string // todo: remove from result?