Cloudreve/pkg/mediameta/exif.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
}
}