2020-07-11 16:46:29 +02:00
package meta
import (
"fmt"
"path/filepath"
"reflect"
2020-07-13 10:41:45 +02:00
"runtime/debug"
2021-07-12 21:41:44 +02:00
"strconv"
2020-07-11 16:46:29 +02:00
"strings"
"time"
2022-06-24 06:59:22 +02:00
"github.com/photoprism/photoprism/pkg/video"
2022-04-15 09:42:07 +02:00
"github.com/photoprism/photoprism/pkg/projection"
"github.com/photoprism/photoprism/pkg/clean"
2020-07-23 15:34:20 +02:00
"github.com/photoprism/photoprism/pkg/rnd"
2020-07-11 16:46:29 +02:00
"github.com/photoprism/photoprism/pkg/txt"
"github.com/tidwall/gjson"
2021-07-17 15:21:03 +02:00
"gopkg.in/photoprism/go-tz.v2/tz"
2020-07-11 16:46:29 +02:00
)
2021-07-13 17:56:26 +02:00
const MimeVideoMP4 = "video/mp4"
2021-07-13 18:08:47 +02:00
const MimeQuicktime = "video/quicktime"
2021-07-13 17:56:26 +02:00
2021-07-12 21:41:44 +02:00
// Exiftool parses JSON sidecar data as created by Exiftool.
2020-07-11 16:46:29 +02:00
func ( data * Data ) Exiftool ( jsonData [ ] byte , originalName string ) ( err error ) {
defer func ( ) {
if e := recover ( ) ; e != nil {
2020-07-13 10:41:45 +02:00
err = fmt . Errorf ( "metadata: %s (exiftool panic)\nstack: %s" , e , debug . Stack ( ) )
2020-07-11 16:46:29 +02:00
}
} ( )
j := gjson . GetBytes ( jsonData , "@flatten|@join" )
2022-08-24 17:50:22 +02:00
logName := "json file"
if originalName != "" {
logName = clean . Log ( filepath . Base ( originalName ) )
}
2020-07-11 16:46:29 +02:00
if ! j . IsObject ( ) {
2022-08-24 17:50:22 +02:00
return fmt . Errorf ( "metadata: data is not an object in %s (exiftool)" , logName )
2020-07-11 16:46:29 +02:00
}
2022-07-22 11:46:53 +02:00
data . json = make ( map [ string ] string )
2020-07-11 16:46:29 +02:00
jsonValues := j . Map ( )
for key , val := range jsonValues {
2022-07-22 11:46:53 +02:00
data . json [ key ] = val . String ( )
2020-07-11 16:46:29 +02:00
}
2022-07-22 11:46:53 +02:00
if fileName , ok := data . json [ "FileName" ] ; ok && fileName != "" && originalName != "" && fileName != originalName {
2022-04-15 09:42:07 +02:00
return fmt . Errorf ( "metadata: original name %s does not match %s (exiftool)" , clean . Log ( originalName ) , clean . Log ( fileName ) )
2022-08-24 17:50:22 +02:00
} else if fileName != "" && originalName == "" {
logName = clean . Log ( filepath . Base ( fileName ) )
2020-07-11 16:46:29 +02:00
}
v := reflect . ValueOf ( data ) . Elem ( )
// Iterate through all config fields
for i := 0 ; i < v . NumField ( ) ; i ++ {
fieldValue := v . Field ( i )
tagData := v . Type ( ) . Field ( i ) . Tag . Get ( "meta" )
// Automatically assign values to fields with "flag" tag
if tagData != "" {
tagValues := strings . Split ( tagData , "," )
var jsonValue gjson . Result
var tagValue string
for _ , tagValue = range tagValues {
if r , ok := jsonValues [ tagValue ] ; ! ok {
continue
2022-07-22 12:38:25 +02:00
} else if txt . Empty ( r . String ( ) ) {
continue
2020-07-11 16:46:29 +02:00
} else {
jsonValue = r
break
}
}
2020-12-04 22:06:23 +01:00
// Skip empty values.
2021-04-25 14:17:34 +02:00
if ! jsonValue . Exists ( ) {
2020-07-11 16:46:29 +02:00
continue
}
switch t := fieldValue . Interface ( ) . ( type ) {
case time . Time :
2021-04-25 14:17:34 +02:00
if ! fieldValue . IsZero ( ) {
continue
}
2022-03-25 16:31:09 +01:00
if dateTime := txt . DateTime ( jsonValue . String ( ) , "" ) ; ! dateTime . IsZero ( ) {
fieldValue . Set ( reflect . ValueOf ( dateTime ) )
2020-07-11 16:46:29 +02:00
}
case time . Duration :
2021-04-25 14:17:34 +02:00
if ! fieldValue . IsZero ( ) {
continue
}
2020-07-11 16:46:29 +02:00
fieldValue . Set ( reflect . ValueOf ( StringToDuration ( jsonValue . String ( ) ) ) )
case int , int64 :
2021-04-25 14:17:34 +02:00
if ! fieldValue . IsZero ( ) {
continue
}
2022-07-22 11:46:53 +02:00
if intVal := jsonValue . Int ( ) ; intVal != 0 {
fieldValue . SetInt ( intVal )
} else if intVal = txt . Int64 ( jsonValue . String ( ) ) ; intVal != 0 {
fieldValue . SetInt ( intVal )
}
2020-07-11 16:46:29 +02:00
case float32 , float64 :
2021-04-25 14:17:34 +02:00
if ! fieldValue . IsZero ( ) {
continue
}
2022-07-22 11:46:53 +02:00
if f := jsonValue . Float ( ) ; f != 0 {
fieldValue . SetFloat ( f )
} else if f = txt . Float64 ( jsonValue . String ( ) ) ; f != 0 {
fieldValue . SetFloat ( f )
}
2020-07-11 16:46:29 +02:00
case uint , uint64 :
2021-04-25 14:17:34 +02:00
if ! fieldValue . IsZero ( ) {
continue
}
2022-07-22 11:46:53 +02:00
if uintVal := jsonValue . Uint ( ) ; uintVal > 0 {
fieldValue . SetUint ( uintVal )
} else if intVal := txt . Int64 ( jsonValue . String ( ) ) ; intVal > 0 {
fieldValue . SetUint ( uint64 ( intVal ) )
}
2021-04-25 14:17:34 +02:00
case [ ] string :
existing := fieldValue . Interface ( ) . ( [ ] string )
fieldValue . Set ( reflect . ValueOf ( txt . AddToWords ( existing , strings . TrimSpace ( jsonValue . String ( ) ) ) ) )
case Keywords :
existing := fieldValue . Interface ( ) . ( Keywords )
fieldValue . Set ( reflect . ValueOf ( txt . AddToWords ( existing , strings . TrimSpace ( jsonValue . String ( ) ) ) ) )
2022-04-15 09:42:07 +02:00
case projection . Type :
2022-06-20 11:41:41 +02:00
if ! fieldValue . IsZero ( ) {
continue
}
2022-04-15 09:42:07 +02:00
fieldValue . Set ( reflect . ValueOf ( projection . Type ( strings . TrimSpace ( jsonValue . String ( ) ) ) ) )
2020-07-11 16:46:29 +02:00
case string :
2021-04-25 14:17:34 +02:00
if ! fieldValue . IsZero ( ) {
continue
}
2020-07-11 16:46:29 +02:00
fieldValue . SetString ( strings . TrimSpace ( jsonValue . String ( ) ) )
case bool :
2021-04-25 14:17:34 +02:00
if ! fieldValue . IsZero ( ) {
continue
}
2020-07-11 16:46:29 +02:00
fieldValue . SetBool ( jsonValue . Bool ( ) )
default :
2022-01-05 11:40:44 +01:00
log . Warnf ( "metadata: cannot assign value of type %s to %s (exiftool)" , t , tagValue )
2020-07-11 16:46:29 +02:00
}
}
}
2022-04-13 22:17:59 +02:00
// Nanoseconds.
if data . TakenNs <= 0 {
for _ , name := range exifSubSecTags {
2022-07-22 11:46:53 +02:00
if s := data . json [ name ] ; txt . IsPosInt ( s ) {
2022-04-13 22:17:59 +02:00
data . TakenNs = txt . Int ( s + strings . Repeat ( "0" , 9 - len ( s ) ) )
break
}
}
}
2020-12-04 22:06:23 +01:00
// Set latitude and longitude if known and not already set.
if data . Lat == 0 && data . Lng == 0 {
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 )
}
2020-07-11 16:46:29 +02:00
}
2021-07-12 21:41:44 +02:00
if data . Altitude == 0 {
// Parseable floating point number?
2022-07-22 11:46:53 +02:00
if fl := GpsFloatRegexp . FindAllString ( data . json [ "GPSAltitude" ] , - 1 ) ; len ( fl ) != 1 {
2021-07-12 21:41:44 +02:00
// Ignore.
} else if alt , err := strconv . ParseFloat ( fl [ 0 ] , 64 ) ; err == nil && alt != 0 {
data . Altitude = int ( alt )
}
}
2021-07-13 17:56:26 +02:00
hasTimeOffset := false
2022-08-24 17:50:22 +02:00
// Has Media Create Date?
if ! data . CreatedAt . IsZero ( ) {
data . TakenAt = data . CreatedAt
}
// Fallback to GPS UTC Time?
2022-04-13 22:17:59 +02:00
if data . TakenAt . IsZero ( ) && data . TakenAtLocal . IsZero ( ) && ! data . TakenGps . IsZero ( ) {
data . TimeZone = time . UTC . String ( )
data . TakenAt = data . TakenGps . UTC ( )
data . TakenAtLocal = time . Time { }
}
2022-08-24 17:50:22 +02:00
// Check plausibility of the local <> UTC time difference.
if ! data . TakenAt . IsZero ( ) && ! data . TakenAtLocal . IsZero ( ) {
if d := data . TakenAt . Sub ( data . TakenAtLocal ) . Abs ( ) ; d > time . Hour * 27 {
2022-08-24 21:16:16 +02:00
log . Infof ( "metadata: %s has an invalid local time offset (%s)" , logName , d . String ( ) )
log . Debugf ( "metadata: %s was taken at %s, local time %s, create time %s, time zone %s" , logName , clean . Log ( data . TakenAt . UTC ( ) . String ( ) ) , clean . Log ( data . TakenAtLocal . String ( ) ) , clean . Log ( data . CreatedAt . String ( ) ) , clean . Log ( data . TimeZone ) )
2022-08-24 17:50:22 +02:00
data . TakenAtLocal = data . TakenAt
data . TakenAt = data . TakenAt . UTC ( )
}
}
// Has time zone offset?
2021-07-13 17:56:26 +02:00
if _ , offset := data . TakenAtLocal . Zone ( ) ; offset != 0 && ! data . TakenAtLocal . IsZero ( ) {
hasTimeOffset = true
2022-08-24 17:50:22 +02:00
} else if mt , ok := data . json [ "MIMEType" ] ; ok && data . TakenAtLocal . IsZero ( ) && ( mt == MimeVideoMP4 || mt == MimeQuicktime ) {
2021-07-13 18:08:47 +02:00
// Assume default time zone for MP4 & Quicktime videos is UTC.
2021-07-13 17:56:26 +02:00
// see https://exiftool.org/TagNames/QuickTime.html
2022-08-24 20:14:46 +02:00
log . Debugf ( "metadata: %s uses utc by default (%s)" , logName , clean . Log ( mt ) )
2021-07-13 17:56:26 +02:00
data . TimeZone = time . UTC . String ( )
data . TakenAt = data . TakenAt . UTC ( )
data . TakenAtLocal = time . Time { }
}
2020-07-11 16:46:29 +02:00
// 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 ]
}
2021-07-13 17:56:26 +02:00
if loc , err := time . LoadLocation ( data . TimeZone ) ; err != nil {
log . Warnf ( "metadata: unknown time zone %s (exiftool)" , data . TimeZone )
} else if ! data . TakenAtLocal . IsZero ( ) {
if tl , err := time . ParseInLocation ( "2006:01:02 15:04:05" , data . TakenAtLocal . Format ( "2006:01:02 15:04:05" ) , loc ) ; err == nil {
2020-12-22 01:52:36 +01:00
if localUtc , err := time . ParseInLocation ( "2006:01:02 15:04:05" , data . TakenAtLocal . Format ( "2006:01:02 15:04:05" ) , time . UTC ) ; err == nil {
data . TakenAtLocal = localUtc
}
2022-04-13 22:17:59 +02:00
data . TakenAt = tl . Truncate ( time . Second ) . UTC ( )
2020-07-11 16:46:29 +02:00
} else {
log . Errorf ( "metadata: %s (exiftool)" , err . Error ( ) ) // this should never happen
}
2021-07-13 17:56:26 +02:00
} else if ! data . TakenAt . IsZero ( ) {
if localUtc , err := time . ParseInLocation ( "2006:01:02 15:04:05" , data . TakenAt . In ( loc ) . Format ( "2006:01:02 15:04:05" ) , time . UTC ) ; err == nil {
data . TakenAtLocal = localUtc
data . TakenAt = data . TakenAt . UTC ( )
} else {
log . Errorf ( "metadata: %s (exiftool)" , err . Error ( ) ) // this should never happen
}
2020-07-11 16:46:29 +02:00
}
2021-07-13 17:56:26 +02:00
} else if hasTimeOffset {
2020-12-22 07:47:16 +01:00
if localUtc , err := time . ParseInLocation ( "2006:01:02 15:04:05" , data . TakenAtLocal . Format ( "2006:01:02 15:04:05" ) , time . UTC ) ; err == nil {
data . TakenAtLocal = localUtc
}
2022-04-13 22:17:59 +02:00
data . TakenAt = data . TakenAt . Truncate ( time . Second ) . UTC ( )
2020-07-11 16:46:29 +02:00
}
2021-07-13 17:56:26 +02:00
// Set local time if still empty.
if data . TakenAtLocal . IsZero ( ) && ! data . TakenAt . IsZero ( ) {
if loc , err := time . LoadLocation ( data . TimeZone ) ; data . TimeZone == "" || err != nil {
data . TakenAtLocal = data . TakenAt
} else if localUtc , err := time . ParseInLocation ( "2006:01:02 15:04:05" , data . TakenAt . In ( loc ) . Format ( "2006:01:02 15:04:05" ) , time . UTC ) ; err == nil {
data . TakenAtLocal = localUtc
data . TakenAt = data . TakenAt . UTC ( )
} else {
log . Errorf ( "metadata: %s (exiftool)" , err . Error ( ) ) // this should never happen
}
}
2022-04-13 22:17:59 +02:00
// Add nanoseconds to the calculated UTC and local time.
if data . TakenAt . Nanosecond ( ) == 0 {
if ns := time . Duration ( data . TakenNs ) ; ns > 0 && ns <= time . Second {
data . TakenAt . Truncate ( time . Second ) . UTC ( ) . Add ( ns )
data . TakenAtLocal . Truncate ( time . Second ) . Add ( ns )
}
}
2022-06-20 11:41:41 +02:00
// Use actual image width and height if available, see issue #2447.
if jsonValues [ "ImageWidth" ] . Exists ( ) && jsonValues [ "ImageHeight" ] . Exists ( ) {
if val := jsonValues [ "ImageWidth" ] . Int ( ) ; val > 0 {
data . Width = int ( val )
}
if val := jsonValues [ "ImageHeight" ] . Int ( ) ; val > 0 {
data . Height = int ( val )
}
}
// Image orientation, see https://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/.
2022-07-22 11:46:53 +02:00
if orientation , ok := data . json [ "Orientation" ] ; ok && orientation != "" {
2020-07-11 16:46:29 +02:00
switch orientation {
case "1" , "Horizontal (normal)" :
data . Orientation = 1
case "2" :
data . Orientation = 2
case "3" , "Rotate 180 CW" :
data . Orientation = 3
case "4" :
data . Orientation = 4
case "5" :
data . Orientation = 5
case "6" , "Rotate 90 CW" :
data . Orientation = 6
case "7" :
data . Orientation = 7
case "8" , "Rotate 270 CW" :
data . Orientation = 8
}
}
if data . Orientation == 0 {
// Set orientation based on rotation.
switch data . Rotation {
case 0 :
data . Orientation = 1
case - 180 , 180 :
data . Orientation = 3
case 90 :
data . Orientation = 6
case - 90 , 270 :
data . Orientation = 8
}
}
2022-06-24 06:59:22 +02:00
// Normalize codec name.
2020-07-11 16:46:29 +02:00
data . Codec = strings . ToLower ( data . Codec )
2022-06-24 06:59:22 +02:00
if strings . Contains ( data . Codec , CodecJpeg ) { // JPEG Image?
2020-07-11 16:46:29 +02:00
data . Codec = CodecJpeg
2022-06-24 06:59:22 +02:00
} else if c , ok := video . Codecs [ data . Codec ] ; ok { // Video codec?
data . Codec = string ( c )
} else if strings . HasPrefix ( data . Codec , "a_" ) { // Audio codec?
data . Codec = ""
2020-07-11 16:46:29 +02:00
}
// Validate and normalize optional DocumentID.
2020-07-23 15:34:20 +02:00
if data . DocumentID != "" {
data . DocumentID = rnd . SanitizeUUID ( data . DocumentID )
2020-07-11 16:46:29 +02:00
}
// Validate and normalize optional InstanceID.
2020-07-23 15:34:20 +02:00
if data . InstanceID != "" {
data . InstanceID = rnd . SanitizeUUID ( data . InstanceID )
2020-07-11 16:46:29 +02:00
}
2022-04-15 09:42:07 +02:00
if projection . Equirectangular . Equal ( data . Projection ) {
2021-04-25 14:17:34 +02:00
data . AddKeywords ( KeywordPanorama )
2020-07-16 13:02:48 +02:00
}
2022-01-05 16:37:19 +01:00
if data . Description != "" {
data . AutoAddKeywords ( data . Description )
data . Description = SanitizeDescription ( data . Description )
}
2020-07-11 16:46:29 +02:00
data . Title = SanitizeTitle ( data . Title )
2020-10-19 11:50:54 +02:00
data . Subject = SanitizeMeta ( data . Subject )
data . Artist = SanitizeMeta ( data . Artist )
2020-07-11 16:46:29 +02:00
return nil
}