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\\d{4})|\\D{4})\\D((?P\\d{2})|\\D{2})\\D((?P\\d{2})|\\D{2})\\D((?P\\d{2})|\\D{2})\\D((?P\\d{2})|\\D{2})\\D((?P\\d{2})|\\D{2})(\\.(?P\\d+))?(?P\\D)?(?P\\d{2})?\\D?(?P\\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, "": 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 } }