mirror of https://github.com/cloudreve/Cloudreve
925 lines
23 KiB
Go
925 lines
23 KiB
Go
package mediameta
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
|
"github.com/dsoprea/go-exif/v3"
|
|
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
|
heicexif "github.com/dsoprea/go-heic-exif-extractor"
|
|
jpegstructure "github.com/dsoprea/go-jpeg-image-structure"
|
|
pngstructure "github.com/dsoprea/go-png-image-structure"
|
|
tiffstructure "github.com/dsoprea/go-tiff-image-structure"
|
|
riimage "github.com/dsoprea/go-utility/image"
|
|
)
|
|
|
|
var (
|
|
exifExts = []string{
|
|
"jpg",
|
|
"jpeg",
|
|
"png",
|
|
"heic",
|
|
"heif",
|
|
"tiff",
|
|
"avif",
|
|
// R
|
|
"3fr", "ari", "arw", "bay", "braw", "crw", "cr2", "cr3", "cap", "data", "dcs", "dcr", "dng", "drf", "eip", "erf", "fff", "gpr", "iiq", "k25", "kdc", "mdc", "mef", "mos", "mrw", "nef", "nrw", "obm", "orf", "pef", "ptx", "pxn", "r3d", "raf", "raw", "rwl", "rw2", "rwz", "sr2", "srf", "srw", "tif", "x3f",
|
|
}
|
|
exifIfdMapping *exifcommon.IfdMapping
|
|
exifTagIndex = exif.NewTagIndex()
|
|
exifDateTimeTags = []string{"DateTimeOriginal", "DateTimeCreated", "CreateDate", "DateTime", "DateTimeDigitized"}
|
|
ExifDateTimeMatch = make(map[string]int)
|
|
ExifDateTimeRegexp = regexp.MustCompile("((?P<year>\\d{4})|\\D{4})\\D((?P<month>\\d{2})|\\D{2})\\D((?P<day>\\d{2})|\\D{2})\\D((?P<h>\\d{2})|\\D{2})\\D((?P<m>\\d{2})|\\D{2})\\D((?P<s>\\d{2})|\\D{2})(\\.(?P<subsec>\\d+))?(?P<z>\\D)?(?P<zh>\\d{2})?\\D?(?P<zm>\\d{2})?")
|
|
YearMax = time.Now().Add(OneYear * 3).Year()
|
|
UnwantedDescriptions = map[string]bool{
|
|
"Created by Imlib": true, // Apps
|
|
"iClarified": true,
|
|
"OLYMPUS DIGITAL CAMERA": true, // Olympus
|
|
"SAMSUNG": true, // Samsung
|
|
"SAMSUNG CAMERA PICTURES": true,
|
|
"<Digimax i5, Samsung #1>": true,
|
|
"SONY DSC": true, // Sony
|
|
"rhdr": true, // Huawei
|
|
"hdrpl": true,
|
|
"oznorWO": true,
|
|
"frontbhdp": true,
|
|
"fbt": true,
|
|
"rbt": true,
|
|
"ptr": true,
|
|
"fbthdr": true,
|
|
"btr": true,
|
|
"mon": true,
|
|
"nor": true,
|
|
"dav": true,
|
|
"mde": true,
|
|
"mde_soft": true,
|
|
"edf": true,
|
|
"btfmdn": true,
|
|
"btf": true,
|
|
"btfhdr": true,
|
|
"frem": true,
|
|
"oznor": true,
|
|
"rpt": true,
|
|
"burst": true,
|
|
"sdr_HDRB": true,
|
|
"cof": true,
|
|
"qrf": true,
|
|
"fshbty": true,
|
|
"binary comment": true, // Other
|
|
"default": true,
|
|
"Exif_JPEG_PICTURE": true,
|
|
"DVC 10.1 HDMI": true,
|
|
"charset=Ascii": true,
|
|
}
|
|
)
|
|
|
|
const (
|
|
OneYear = time.Hour * 24 * 365
|
|
LatMax = 90
|
|
LngMax = 180
|
|
|
|
GpsLat = "latitude"
|
|
GpsLng = "longitude"
|
|
GpsAttitude = "altitude"
|
|
Artist = "artist"
|
|
Copyright = "copyright"
|
|
CameraModel = "camera_model"
|
|
CameraMake = "camera_make"
|
|
CameraOwnerName = "camera_owner"
|
|
BodySerialNumber = "body_serial"
|
|
LensMake = "lens_make"
|
|
LensModel = "lens_model"
|
|
Software = "software"
|
|
ExposureTime = "exposure_time"
|
|
FNumber = "f"
|
|
ApertureValue = "aperture"
|
|
FocalLength = "focal_length"
|
|
ISOSpeedRatings = "iso"
|
|
PixelXDimension = "x"
|
|
PixelYDimension = "y"
|
|
Orientation = "orientation"
|
|
TakenAt = "taken_at"
|
|
Flash = "flash"
|
|
ImageDescription = "des"
|
|
ProjectionType = "projection_type"
|
|
ExposureBiasValue = "exposure_bias"
|
|
)
|
|
|
|
func init() {
|
|
exifIfdMapping = exifcommon.NewIfdMapping()
|
|
_ = exifcommon.LoadStandardIfds(exifIfdMapping)
|
|
names := ExifDateTimeRegexp.SubexpNames()
|
|
for i := 0; i < len(names); i++ {
|
|
if name := names[i]; name != "" {
|
|
ExifDateTimeMatch[name] = i
|
|
}
|
|
}
|
|
}
|
|
|
|
type exifExtractor struct {
|
|
settings setting.Provider
|
|
l logging.Logger
|
|
}
|
|
|
|
func newExifExtractor(settings setting.Provider, l logging.Logger) *exifExtractor {
|
|
return &exifExtractor{
|
|
settings: settings,
|
|
l: l,
|
|
}
|
|
}
|
|
|
|
func (e *exifExtractor) Exts() []string {
|
|
return exifExts
|
|
}
|
|
|
|
// Reference: https://github.com/photoprism/photoprism/blob/602097635f1c84d91f2d919f7aedaef7a07fc458/internal/meta/exif.go
|
|
func (e *exifExtractor) Extract(ctx context.Context, ext string, source entitysource.EntitySource) ([]driver.MediaMeta, error) {
|
|
localLimit, remoteLimit := e.settings.MediaMetaExifSizeLimit(ctx)
|
|
if err := checkFileSize(localLimit, remoteLimit, source); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
bruteForce := e.settings.MediaMetaExifBruteForce(ctx)
|
|
var (
|
|
err error
|
|
exifData []byte
|
|
)
|
|
parser := getExifParser(ext)
|
|
if parser == nil {
|
|
if !bruteForce {
|
|
return nil, errors.New("no available exif parser found")
|
|
}
|
|
|
|
} else {
|
|
var res riimage.MediaContext
|
|
res, err = parser.Parse(source, int(source.Entity().Size()))
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to parse exif: %s", err)
|
|
} else {
|
|
_, exifData, err = res.Exif()
|
|
if err != nil {
|
|
err = fmt.Errorf("failed to parse exif root: %s", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !bruteForce && err != nil {
|
|
return nil, err
|
|
} else if bruteForce && (err != nil || parser == nil) {
|
|
e.l.Debug("Failed to parse exif: %s, trying brute force.", err)
|
|
exifData, err = exif.SearchAndExtractExifWithReader(source)
|
|
if err != nil {
|
|
if errors.Is(err, exif.ErrNoExif) {
|
|
e.l.Debug("No exif data found")
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to brute force to parse exif: %s", err)
|
|
}
|
|
}
|
|
|
|
entries, _, err := exif.GetFlatExifData(exifData, &exif.ScanOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse exif entries: %s", err)
|
|
}
|
|
|
|
exifMap := make(map[string]string, len(entries))
|
|
for _, tag := range entries {
|
|
s := strings.Split(tag.FormattedFirst, "\x00")
|
|
if tag.TagName == "" || len(s) == 0 {
|
|
} else if s[0] != "" && (exifMap[tag.TagName] == "" || tag.IfdPath != exif.ThumbnailFqIfdPath) {
|
|
exifMap[tag.TagName] = s[0]
|
|
}
|
|
}
|
|
|
|
if len(exifMap) == 0 {
|
|
return nil, errors.New("no exif data found")
|
|
}
|
|
|
|
metas := make([]driver.MediaMeta, 0)
|
|
takenTimeGps := time.Time{}
|
|
|
|
// Extract GPS info
|
|
var ifdIndex exif.IfdIndex
|
|
_, ifdIndex, err = exif.Collect(exifIfdMapping, exifTagIndex, exifData)
|
|
if err != nil {
|
|
e.l.Debug("Failed to collect exif data: %s", err)
|
|
} else {
|
|
var ifd *exif.Ifd
|
|
if ifd, err = ifdIndex.RootIfd.ChildWithIfdPath(exifcommon.IfdGpsInfoStandardIfdIdentity); err == nil {
|
|
var gi *exif.GpsInfo
|
|
if gi, err = ifd.GpsInfo(); err != nil {
|
|
e.l.Debug("Failed to collect exif gps data: %s", err)
|
|
} else {
|
|
if !math.IsNaN(gi.Latitude.Decimal()) && !math.IsNaN(gi.Longitude.Decimal()) {
|
|
lat, lng := NormalizeGPS(gi.Latitude.Decimal(), gi.Longitude.Decimal())
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: GpsLat,
|
|
Value: fmt.Sprintf("%f", lat),
|
|
}, driver.MediaMeta{
|
|
Key: GpsLng,
|
|
Value: fmt.Sprintf("%f", lng),
|
|
})
|
|
} else if gi.Altitude != 0 || !gi.Timestamp.IsZero() {
|
|
e.l.Warning("GPS data is invalid: %s", gi.String())
|
|
}
|
|
|
|
if gi.Altitude != 0 {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: GpsAttitude,
|
|
Value: fmt.Sprintf("%d", gi.Altitude),
|
|
})
|
|
}
|
|
|
|
if !gi.Timestamp.IsZero() {
|
|
takenTimeGps = gi.Timestamp
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
metas = append(metas, ExtractExifMap(exifMap, takenTimeGps)...)
|
|
for i := 0; i < len(metas); i++ {
|
|
metas[i].Type = driver.MetaTypeExif
|
|
}
|
|
|
|
return metas, nil
|
|
}
|
|
|
|
func ExtractExifMap(exifMap map[string]string, gpsTime time.Time) []driver.MediaMeta {
|
|
metas := make([]driver.MediaMeta, 0)
|
|
if value, ok := exifMap["Artist"]; ok {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: Artist,
|
|
Value: SanitizeMeta(value),
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["Copyright"]; ok {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: Copyright,
|
|
Value: SanitizeString(value),
|
|
})
|
|
}
|
|
|
|
cameraMode := ""
|
|
if value, ok := exifMap["CameraModel"]; ok && !IsUInt(value) {
|
|
cameraMode = SanitizeString(value)
|
|
} else if value, ok = exifMap["Model"]; ok && !IsUInt(value) {
|
|
cameraMode = SanitizeString(value)
|
|
} else if value, ok = exifMap["UniqueCameraModel"]; ok && !IsUInt(value) {
|
|
cameraMode = SanitizeString(value)
|
|
}
|
|
if cameraMode != "" {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: CameraModel,
|
|
Value: cameraMode,
|
|
})
|
|
}
|
|
|
|
cameraMake := ""
|
|
if value, ok := exifMap["CameraMake"]; ok && !IsUInt(value) {
|
|
cameraMake = SanitizeString(value)
|
|
} else if value, ok = exifMap["Make"]; ok && !IsUInt(value) {
|
|
cameraMake = SanitizeString(value)
|
|
}
|
|
if cameraMake != "" {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: CameraMake,
|
|
Value: cameraMake,
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["CameraOwnerName"]; ok {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: CameraOwnerName,
|
|
Value: SanitizeString(value),
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["BodySerialNumber"]; ok {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: BodySerialNumber,
|
|
Value: SanitizeString(value),
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["LensMake"]; ok && !IsUInt(value) {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: LensMake,
|
|
Value: SanitizeString(value),
|
|
})
|
|
}
|
|
|
|
lens := ""
|
|
if value, ok := exifMap["LensModel"]; ok && !IsUInt(value) {
|
|
lens = SanitizeString(value)
|
|
} else if value, ok = exifMap["Lens"]; ok && !IsUInt(value) {
|
|
lens = SanitizeString(value)
|
|
}
|
|
if lens != "" {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: LensModel,
|
|
Value: lens,
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["Software"]; ok {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: Software,
|
|
Value: SanitizeString(value),
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["ExposureTime"]; ok {
|
|
value = strings.TrimSuffix(value, " sec.")
|
|
if n := strings.Split(value, "/"); len(n) == 2 {
|
|
if n[0] != "1" && len(n[0]) < len(n[1]) {
|
|
n0, _ := strconv.ParseUint(n[0], 10, 64)
|
|
if n1, err := strconv.ParseUint(n[1], 10, 64); err == nil && n0 > 0 && n1 > 0 {
|
|
value = fmt.Sprintf("1/%d", n1/n0)
|
|
}
|
|
}
|
|
}
|
|
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: ExposureTime,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["ExposureBiasValue"]; ok {
|
|
if n := strings.Split(value, "/"); len(n) == 2 {
|
|
n0, _ := strconv.ParseInt(n[0], 10, 64)
|
|
if n1, err := strconv.ParseInt(n[1], 10, 64); err == nil {
|
|
v := "0"
|
|
v = fmt.Sprintf("%f", float64(n0)/float64(n1))
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: ExposureBiasValue,
|
|
Value: v,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if value, ok := exifMap["FNumber"]; ok {
|
|
values := strings.Split(value, "/")
|
|
|
|
if len(values) == 2 && values[1] != "0" && values[1] != "" {
|
|
number, _ := strconv.ParseFloat(values[0], 64)
|
|
denom, _ := strconv.ParseFloat(values[1], 64)
|
|
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: FNumber,
|
|
Value: fmt.Sprintf("%f", float32(math.Round((number/denom)*1000)/1000)),
|
|
})
|
|
}
|
|
}
|
|
|
|
if value, ok := exifMap["ApertureValue"]; ok {
|
|
values := strings.Split(value, "/")
|
|
|
|
if len(values) == 2 && values[1] != "0" && values[1] != "" {
|
|
number, _ := strconv.ParseFloat(values[0], 64)
|
|
denom, _ := strconv.ParseFloat(values[1], 64)
|
|
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: ApertureValue,
|
|
Value: fmt.Sprintf("%f", float32(math.Round((number/denom)*1000)/1000)),
|
|
})
|
|
}
|
|
}
|
|
|
|
focalLength := ""
|
|
if value, ok := exifMap["FocalLengthIn35mmFilm"]; ok {
|
|
focalLength = value
|
|
} else if v, ok := exifMap["FocalLength"]; ok {
|
|
values := strings.Split(v, "/")
|
|
|
|
if len(values) == 2 && values[1] != "0" && values[1] != "" {
|
|
number, _ := strconv.ParseFloat(values[0], 64)
|
|
denom, _ := strconv.ParseFloat(values[1], 64)
|
|
|
|
focalLength = strconv.Itoa(int(math.Round((number/denom)*1000) / 1000))
|
|
}
|
|
}
|
|
if focalLength != "" {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: FocalLength,
|
|
Value: focalLength,
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["ISOSpeedRatings"]; ok {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: ISOSpeedRatings,
|
|
Value: value,
|
|
})
|
|
}
|
|
|
|
width := ""
|
|
if value, ok := exifMap["PixelXDimension"]; ok {
|
|
width = value
|
|
} else if value, ok := exifMap["ImageWidth"]; ok {
|
|
width = value
|
|
}
|
|
if width != "" {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: PixelXDimension,
|
|
Value: width,
|
|
})
|
|
}
|
|
|
|
height := ""
|
|
if value, ok := exifMap["PixelYDimension"]; ok {
|
|
height = value
|
|
} else if value, ok := exifMap["ImageLength"]; ok {
|
|
height = value
|
|
}
|
|
if height != "" {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: PixelYDimension,
|
|
Value: height,
|
|
})
|
|
}
|
|
|
|
orientation := "1"
|
|
if value, ok := exifMap["Orientation"]; ok {
|
|
orientation = value
|
|
}
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: Orientation,
|
|
Value: orientation,
|
|
})
|
|
|
|
takeTime := time.Time{}
|
|
for _, name := range exifDateTimeTags {
|
|
if dateTime := DateTime(exifMap[name], ""); !dateTime.IsZero() {
|
|
takeTime = dateTime
|
|
break
|
|
}
|
|
}
|
|
if takeTime.IsZero() {
|
|
takeTime = gpsTime.UTC()
|
|
}
|
|
|
|
if !takeTime.IsZero() {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: TakenAt,
|
|
Value: takeTime.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["Flash"]; ok {
|
|
flash := "0"
|
|
if i, err := strconv.Atoi(value); err == nil && i&1 == 1 {
|
|
flash = "1"
|
|
}
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: Flash,
|
|
Value: flash,
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["ImageDescription"]; ok {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: ImageDescription,
|
|
Value: SanitizeDescription(value),
|
|
})
|
|
}
|
|
|
|
if value, ok := exifMap["ProjectionType"]; ok {
|
|
metas = append(metas, driver.MediaMeta{
|
|
Key: ProjectionType,
|
|
Value: SanitizeString(value),
|
|
})
|
|
}
|
|
|
|
return metas
|
|
}
|
|
|
|
type (
|
|
exifParser interface {
|
|
Parse(rs io.ReadSeeker, size int) (ec riimage.MediaContext, err error)
|
|
}
|
|
)
|
|
|
|
func getExifParser(ext string) exifParser {
|
|
switch ext {
|
|
case "jpg", "jpeg":
|
|
return jpegstructure.NewJpegMediaParser()
|
|
case "png":
|
|
return pngstructure.NewPngMediaParser()
|
|
case "tiff":
|
|
return tiffstructure.NewTiffMediaParser()
|
|
case "heic", "heif", "avif":
|
|
return heicexif.NewHeicExifMediaParser()
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// NormalizeGPS normalizes the longitude and latitude of the GPS position to a generally valid range.
|
|
func NormalizeGPS(lat, lng float64) (float32, float32) {
|
|
if lat < LatMax || lat > LatMax || lng < LngMax || lng > LngMax {
|
|
// Clip the latitude. Normalise the longitude.
|
|
lat, lng = clipLat(lat), normalizeLng(lng)
|
|
}
|
|
|
|
return float32(lat), float32(lng)
|
|
}
|
|
|
|
func clipLat(lat float64) float64 {
|
|
if lat > LatMax*2 {
|
|
return math.Mod(lat, LatMax)
|
|
} else if lat > LatMax {
|
|
return lat - LatMax
|
|
}
|
|
|
|
if lat < -LatMax*2 {
|
|
return math.Mod(lat, LatMax)
|
|
} else if lat < -LatMax {
|
|
return lat + LatMax
|
|
}
|
|
|
|
return lat
|
|
}
|
|
|
|
func normalizeLng(value float64) float64 {
|
|
return normalizeCoord(value, LngMax)
|
|
}
|
|
|
|
func normalizeCoord(value, max float64) float64 {
|
|
for value < -max {
|
|
value += 2 * max
|
|
}
|
|
for value >= max {
|
|
value -= 2 * max
|
|
}
|
|
return value
|
|
}
|
|
|
|
// SanitizeString removes unwanted character from an exif value string.
|
|
func SanitizeString(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.HasPrefix(s, "string with binary data") {
|
|
return ""
|
|
} else if strings.HasPrefix(s, "(Binary data") {
|
|
return ""
|
|
}
|
|
|
|
return SanitizeUnicode(strings.Replace(s, "\"", "", -1))
|
|
}
|
|
|
|
// SanitizeUnicode returns the string as valid Unicode with whitespace trimmed.
|
|
func SanitizeUnicode(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
|
|
return unicode(strings.TrimSpace(s))
|
|
}
|
|
|
|
// SanitizeMeta normalizes metadata fields that may contain JSON arrays like keywords and subject.
|
|
func SanitizeMeta(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]") {
|
|
var words []string
|
|
|
|
if err := json.Unmarshal([]byte(s), &words); err != nil {
|
|
return s
|
|
}
|
|
|
|
s = strings.Join(words, ", ")
|
|
} else {
|
|
s = SanitizeString(s)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
func unicode(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
for _, c := range s {
|
|
if c == '\uFFFD' {
|
|
continue
|
|
}
|
|
b.WriteRune(c)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func IsUInt(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
|
|
for _, r := range s {
|
|
if r < 48 || r > 57 {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// DateTime parses a time string and returns a valid time.Time if possible.
|
|
func DateTime(s, timeZone string) (t time.Time) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
// Panic? Return unknown time.
|
|
t = time.Time{}
|
|
}
|
|
}()
|
|
|
|
// Ignore defaults.
|
|
if DateTimeDefault(s) {
|
|
return time.Time{}
|
|
}
|
|
|
|
s = strings.TrimLeft(s, " ")
|
|
|
|
// Timestamp too short?
|
|
if len(s) < 4 {
|
|
return time.Time{}
|
|
} else if len(s) > 50 {
|
|
// Clip to max length.
|
|
s = s[:50]
|
|
}
|
|
|
|
// Pad short timestamp with whitespace at the end.
|
|
s = fmt.Sprintf("%-19s", s)
|
|
|
|
v := ExifDateTimeMatch
|
|
m := ExifDateTimeRegexp.FindStringSubmatch(s)
|
|
|
|
// Pattern doesn't match? Return unknown time.
|
|
if len(m) == 0 {
|
|
return time.Time{}
|
|
}
|
|
|
|
// Default to UTC.
|
|
tz := time.UTC
|
|
|
|
// Local time zone currently not supported (undefined).
|
|
if timeZone == time.Local.String() {
|
|
timeZone = ""
|
|
}
|
|
|
|
// Set time zone.
|
|
loc := TimeZone(timeZone)
|
|
|
|
// Location found?
|
|
if loc != nil && timeZone != "" && tz != time.Local {
|
|
tz = loc
|
|
timeZone = tz.String()
|
|
} else {
|
|
timeZone = ""
|
|
}
|
|
|
|
// Does the timestamp contain a time zone offset?
|
|
z := m[v["z"]] // Supported values, if not empty: Z, +, -
|
|
zh := IntVal(m[v["zh"]], 0, 23, 0) // Hours.
|
|
zm := IntVal(m[v["zm"]], 0, 59, 0) // Minutes.
|
|
|
|
// Valid time zone offset found?
|
|
if offset := (zh*60 + zm) * 60; offset > 0 && offset <= 86400 {
|
|
// Offset timezone name example: UTC+03:30
|
|
if z == "+" {
|
|
// Positive offset relative to UTC.
|
|
tz = time.FixedZone(fmt.Sprintf("UTC+%02d:%02d", zh, zm), offset)
|
|
} else if z == "-" {
|
|
// Negative offset relative to UTC.
|
|
tz = time.FixedZone(fmt.Sprintf("UTC-%02d:%02d", zh, zm), -1*offset)
|
|
}
|
|
}
|
|
|
|
var nsec int
|
|
|
|
if subsec := m[v["subsec"]]; subsec != "" {
|
|
nsec = Int(subsec + strings.Repeat("0", 9-len(subsec)))
|
|
} else {
|
|
nsec = 0
|
|
}
|
|
|
|
// Create rounded timestamp from parsed input values.
|
|
// Year 0 is treated separately as it has a special meaning in exiftool. Golang
|
|
// does not seem to accept value 0 for the year, but considers a date to be
|
|
// "zero" when year is 1.
|
|
year := IntVal(m[v["year"]], 0, YearMax, time.Now().Year())
|
|
if year == 0 {
|
|
year = 1
|
|
}
|
|
t = time.Date(
|
|
year,
|
|
time.Month(IntVal(m[v["month"]], 1, 12, 1)),
|
|
IntVal(m[v["day"]], 1, 31, 1),
|
|
IntVal(m[v["h"]], 0, 23, 0),
|
|
IntVal(m[v["m"]], 0, 59, 0),
|
|
IntVal(m[v["s"]], 0, 59, 0),
|
|
nsec,
|
|
tz)
|
|
|
|
if timeZone != "" && loc != nil && loc != tz {
|
|
return t.In(loc)
|
|
}
|
|
|
|
return t
|
|
}
|
|
|
|
// Int converts a string to a signed integer or 0 if invalid.
|
|
func Int(s string) int {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
|
|
result, err := strconv.ParseInt(strings.TrimSpace(s), 10, 32)
|
|
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
return int(result)
|
|
}
|
|
|
|
// IntVal converts a string to a validated integer or a default if invalid.
|
|
func IntVal(s string, min, max, def int) (i int) {
|
|
if s == "" {
|
|
return def
|
|
} else if s[0] == ' ' {
|
|
s = strings.TrimSpace(s)
|
|
}
|
|
|
|
result, err := strconv.ParseInt(s, 10, 32)
|
|
|
|
if err != nil {
|
|
return def
|
|
}
|
|
|
|
i = int(result)
|
|
|
|
if i < min {
|
|
return def
|
|
} else if max != 0 && i > max {
|
|
return def
|
|
}
|
|
|
|
return i
|
|
}
|
|
|
|
// DateTimeDefault tests if the datetime string is not empty and not a default value.
|
|
func DateTimeDefault(s string) bool {
|
|
switch s {
|
|
case "1970-01-01", "1970-01-01 00:00:00", "1970:01:01 00:00:00":
|
|
// Unix epoch.
|
|
return true
|
|
case "1980-01-01", "1980-01-01 00:00:00", "1980:01:01 00:00:00":
|
|
// Windows default.
|
|
return true
|
|
case "2002-12-08 12:00:00", "2002:12:08 12:00:00":
|
|
// Android Bug: https://issuetracker.google.com/issues/36967504
|
|
return true
|
|
default:
|
|
return EmptyDateTime(s)
|
|
}
|
|
}
|
|
|
|
// EmptyDateTime tests if the string is empty or matches an unknown time pattern.
|
|
func EmptyDateTime(s string) bool {
|
|
switch s {
|
|
case "", "-", ":", "z", "Z", "nil", "null", "none", "nan", "NaN":
|
|
return true
|
|
case "0", "00", "0000", "0000:00:00", "00:00:00", "0000-00-00", "00-00-00":
|
|
return true
|
|
case " : : : : ", " - - - - ", " - - : : ":
|
|
// Exif default.
|
|
return true
|
|
case "0000:00:00 00:00:00", "0000-00-00 00-00-00", "0000-00-00 00:00:00":
|
|
return true
|
|
case "0001:01:01 00:00:00", "0001-01-01 00-00-00", "0001-01-01 00:00:00":
|
|
// Go default.
|
|
return true
|
|
case "0001:01:01 00:00:00 +0000 UTC", "0001-01-01 00-00-00 +0000 UTC", "0001-01-01 00:00:00 +0000 UTC":
|
|
// Go default with time zone.
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// TimeZone returns a time zone for the given UTC offset string.
|
|
func TimeZone(offset string) *time.Location {
|
|
if offset == "" {
|
|
// Local time.
|
|
} else if offset == "UTC" || offset == "Z" {
|
|
return time.UTC
|
|
} else if seconds, err := TimeOffset(offset); err == nil {
|
|
if h := seconds / 3600; h > 0 || h < 0 {
|
|
return time.FixedZone(fmt.Sprintf("UTC%+d", h), seconds)
|
|
}
|
|
} else if zone, zoneErr := time.LoadLocation(offset); zoneErr == nil {
|
|
return zone
|
|
}
|
|
|
|
return time.FixedZone("", 0)
|
|
}
|
|
|
|
// TimeOffset returns the UTC time offset in seconds or an error if it is invalid.
|
|
func TimeOffset(utcOffset string) (seconds int, err error) {
|
|
switch utcOffset {
|
|
case "-12", "-12:00", "UTC-12", "UTC-12:00":
|
|
seconds = -12 * 3600
|
|
case "-11", "-11:00", "UTC-11", "UTC-11:00":
|
|
seconds = -11 * 3600
|
|
case "-10", "-10:00", "UTC-10", "UTC-10:00":
|
|
seconds = -10 * 3600
|
|
case "-9", "-09", "-09:00", "UTC-9", "UTC-09:00":
|
|
seconds = -9 * 3600
|
|
case "-8", "-08", "-08:00", "UTC-8", "UTC-08:00":
|
|
seconds = -8 * 3600
|
|
case "-7", "-07", "-07:00", "UTC-7", "UTC-07:00":
|
|
seconds = -7 * 3600
|
|
case "-6", "-06", "-06:00", "UTC-6", "UTC-06:00":
|
|
seconds = -6 * 3600
|
|
case "-5", "-05", "-05:00", "UTC-5", "UTC-05:00":
|
|
seconds = -5 * 3600
|
|
case "-4", "-04", "-04:00", "UTC-4", "UTC-04:00":
|
|
seconds = -4 * 3600
|
|
case "-3", "-03", "-03:00", "UTC-3", "UTC-03:00":
|
|
seconds = -3 * 3600
|
|
case "-2", "-02", "-02:00", "UTC-2", "UTC-02:00":
|
|
seconds = -2 * 3600
|
|
case "-1", "-01", "-01:00", "UTC-1", "UTC-01:00":
|
|
seconds = -1 * 3600
|
|
case "01:00", "+1", "+01", "+01:00", "UTC+1", "UTC+01:00":
|
|
seconds = 1 * 3600
|
|
case "02:00", "+2", "+02", "+02:00", "UTC+2", "UTC+02:00":
|
|
seconds = 2 * 3600
|
|
case "03:00", "+3", "+03", "+03:00", "UTC+3", "UTC+03:00":
|
|
seconds = 3 * 3600
|
|
case "04:00", "+4", "+04", "+04:00", "UTC+4", "UTC+04:00":
|
|
seconds = 4 * 3600
|
|
case "05:00", "+5", "+05", "+05:00", "UTC+5", "UTC+05:00":
|
|
seconds = 5 * 3600
|
|
case "06:00", "+6", "+06", "+06:00", "UTC+6", "UTC+06:00":
|
|
seconds = 6 * 3600
|
|
case "07:00", "+7", "+07", "+07:00", "UTC+7", "UTC+07:00":
|
|
seconds = 7 * 3600
|
|
case "08:00", "+8", "+08", "+08:00", "UTC+8", "UTC+08:00":
|
|
seconds = 8 * 3600
|
|
case "09:00", "+9", "+09", "+09:00", "UTC+9", "UTC+09:00":
|
|
seconds = 9 * 3600
|
|
case "10:00", "+10", "+10:00", "UTC+10", "UTC+10:00":
|
|
seconds = 10 * 3600
|
|
case "11:00", "+11", "+11:00", "UTC+11", "UTC+11:00":
|
|
seconds = 11 * 3600
|
|
case "12:00", "+12", "+12:00", "UTC+12", "UTC+12:00":
|
|
seconds = 12 * 3600
|
|
case "Z", "UTC", "UTC+0", "UTC-0", "UTC+00:00", "UTC-00:00":
|
|
seconds = 0
|
|
default:
|
|
return 0, fmt.Errorf("invalid UTC offset")
|
|
}
|
|
|
|
return seconds, nil
|
|
}
|
|
|
|
func SanitizeDescription(s string) string {
|
|
s = SanitizeString(s)
|
|
|
|
switch {
|
|
case s == "":
|
|
return ""
|
|
case UnwantedDescriptions[s]:
|
|
return ""
|
|
case strings.HasPrefix(s, "DCIM\\") && !strings.Contains(s, " "):
|
|
return ""
|
|
default:
|
|
return s
|
|
}
|
|
}
|