package photoprism import ( "encoding/hex" "fmt" "github.com/brett-lempereur/ish" "github.com/djherbis/times" "github.com/pkg/errors" "github.com/steakknife/hamming" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "log" "math" "net/http" "os" "path/filepath" "strings" "time" ) const ( FileTypeOther = "" FileTypeYaml = "yml" FileTypeJpeg = "jpg" FileTypeRaw = "raw" FileTypeXmp = "xmp" FileTypeAae = "aae" FileTypeMovie = "mov" ) const ( MimeTypeJpeg = "image/jpeg" ) var FileExtensions = map[string]string{ ".crw": FileTypeRaw, ".cr2": FileTypeRaw, ".nef": FileTypeRaw, ".arw": FileTypeRaw, ".dng": FileTypeRaw, ".mov": FileTypeMovie, ".avi": FileTypeMovie, ".yml": FileTypeYaml, ".jpg": FileTypeJpeg, ".thm": FileTypeJpeg, ".jpeg": FileTypeJpeg, ".xmp": FileTypeXmp, ".aae": FileTypeAae, } type MediaFile struct { filename string dateCreated time.Time hash string fileType string mimeType string perceptualHash string tags []string width int height int exifData *ExifData location *Location } func NewMediaFile(filename string) (*MediaFile, error) { if !fileExists(filename) { return nil, fmt.Errorf("file does not exist: %s", filename) } instance := &MediaFile{ filename: filename, fileType: FileTypeOther, } return instance, nil } func (m *MediaFile) GetDateCreated() time.Time { if !m.dateCreated.IsZero() { return m.dateCreated } m.dateCreated = time.Now() info, err := m.GetExifData() if err == nil && !info.DateTime.IsZero() { m.dateCreated = info.DateTime return m.dateCreated } t, err := times.Stat(m.GetFilename()) if err != nil { log.Println(err.Error()) return m.dateCreated } if t.HasBirthTime() { m.dateCreated = t.BirthTime() } else { m.dateCreated = t.ModTime() } return m.dateCreated } func (m *MediaFile) GetCameraModel() string { info, err := m.GetExifData() var result string if err == nil { result = info.CameraModel } return result } func (m *MediaFile) GetCanonicalName() string { var postfix string dateCreated := m.GetDateCreated().UTC() if fileHash := m.GetHash(); len(fileHash) > 12 { postfix = strings.ToUpper(fileHash[:12]) } else { postfix = "NOTFOUND" } result := dateCreated.Format("20060102_150405_") + postfix return result } func (m *MediaFile) GetCanonicalNameFromFile() string { basename := filepath.Base(m.GetFilename()) if end := strings.Index(basename, "."); end != -1 { return basename[:end] // Length of canonical name: 16 + 12 } else { return basename } } func (m *MediaFile) GetPerceptualHash() (string, error) { if m.perceptualHash != "" { return m.perceptualHash, nil } hasher := ish.NewDifferenceHash(8, 8) img, _, err := ish.LoadFile(m.GetFilename()) if err != nil { return "", err } dh, err := hasher.Hash(img) if err != nil { return "", err } m.perceptualHash = hex.EncodeToString(dh) return m.perceptualHash, nil } func (m *MediaFile) GetPerceptualDistance(perceptualHash string) (int, error) { var hash1, hash2 []byte if imageHash, err := m.GetPerceptualHash(); err != nil { return -1, err } else { if decoded, err := hex.DecodeString(imageHash); err != nil { return -1, err } else { hash1 = decoded } } if decoded, err := hex.DecodeString(perceptualHash); err != nil { return -1, err } else { hash2 = decoded } result := hamming.Bytes(hash1, hash2) return result, nil } func (m *MediaFile) GetHash() string { if len(m.hash) == 0 { m.hash = fileHash(m.GetFilename()) } return m.hash } // When editing photos, iPhones create additional files like IMG_E12345.JPG func (m *MediaFile) GetEditedFilename() (result string) { basename := filepath.Base(m.filename) if strings.ToUpper(basename[:4]) == "IMG_" && strings.ToUpper(basename[:5]) != "IMG_E" { result = filepath.Dir(m.filename) + string(os.PathSeparator) + basename[:4] + "E" + basename[4:] } return result } func (m *MediaFile) GetRelatedFiles() (result []*MediaFile, masterFile *MediaFile, err error) { extension := m.GetExtension() baseFilename := m.filename[0 : len(m.filename)-len(extension)] matches, err := filepath.Glob(baseFilename + "*") if err != nil { return result, nil, err } if editedFilename := m.GetEditedFilename(); editedFilename != "" && fileExists(editedFilename) { matches = append(matches, editedFilename) } for _, filename := range matches { resultFile, err := NewMediaFile(filename) if err != nil { continue } if masterFile == nil && resultFile.IsJpeg() { masterFile = resultFile } else if resultFile.IsRaw() { masterFile = resultFile } result = append(result, resultFile) } return result, masterFile, nil } func (m *MediaFile) GetFilename() string { return m.filename } func (m *MediaFile) SetFilename(filename string) { m.filename = filename } func (m *MediaFile) GetMimeType() string { if m.mimeType != "" { return m.mimeType } handle, err := m.openFile() if err != nil { log.Println("Error: Could not open file to determine mime type") return "" } defer handle.Close() // Only the first 512 bytes are used to sniff the content type. buffer := make([]byte, 512) _, err = handle.Read(buffer) if err != nil { log.Println("Error: Could not read file to determine mime type: " + m.GetFilename()) return "" } m.mimeType = http.DetectContentType(buffer) return m.mimeType } func (m *MediaFile) openFile() (*os.File, error) { if handle, err := os.Open(m.filename); err == nil { return handle, nil } else { log.Println(err.Error()) return nil, err } } func (m *MediaFile) Exists() bool { return fileExists(m.GetFilename()) } func (m *MediaFile) Remove() error { return os.Remove(m.GetFilename()) } func (m *MediaFile) Move(newFilename string) error { if err := os.Rename(m.filename, newFilename); err != nil { return err } m.filename = newFilename return nil } func (m *MediaFile) Copy(destinationFilename string) error { file, err := m.openFile() if err != nil { log.Println(err.Error()) return err } defer file.Close() destination, err := os.OpenFile(destinationFilename, os.O_RDWR|os.O_CREATE, 0666) if err != nil { log.Println(err.Error()) return err } defer destination.Close() _, err = io.Copy(destination, file) if err != nil { log.Println(err.Error()) return err } return nil } func (m *MediaFile) GetExtension() string { return strings.ToLower(filepath.Ext(m.filename)) } func (m *MediaFile) IsJpeg() bool { // Don't import/use existing thumbnail files (we create our own) if m.GetExtension() == ".thm" { return false } return m.GetMimeType() == MimeTypeJpeg } func (m *MediaFile) GetType() string { return FileExtensions[m.GetExtension()] } func (m *MediaFile) HasType(typeString string) bool { if typeString == FileTypeJpeg { return m.IsJpeg() } return m.GetType() == typeString } func (m *MediaFile) IsRaw() bool { return m.HasType(FileTypeRaw) } func (m *MediaFile) IsPhoto() bool { return m.IsJpeg() || m.IsRaw() } func (m *MediaFile) GetJpeg() (*MediaFile, error) { if m.IsJpeg() { return m, nil } jpegFilename := m.GetFilename()[0:len(m.GetFilename())-len(filepath.Ext(m.GetFilename()))] + ".jpg" if !fileExists(jpegFilename) { return nil, errors.New("file does not exist") } return NewMediaFile(jpegFilename) } func (m *MediaFile) decodeDimensions() error { if m.IsJpeg() { file, err := os.Open(m.GetFilename()) defer file.Close() if err != nil { return err } size, _, err := image.DecodeConfig(file) if err != nil { return err } m.width = size.Width m.height = size.Height } else { if exif, err := m.GetExifData(); err == nil { m.width = exif.Width m.height = exif.Height } else { return err } } return nil } func (m *MediaFile) GetWidth() int { if m.width <= 0 { m.decodeDimensions() } return m.width } func (m *MediaFile) GetHeight() int { if m.height <= 0 { m.decodeDimensions() } return m.height } func (m *MediaFile) GetAspectRatio() float64 { width := float64(m.GetWidth()) height := float64(m.GetHeight()) if width <= 0 || height <= 0 { return 0 } aspectRatio := width / height return math.Round(aspectRatio*100) / 100 } func (m *MediaFile) GetOrientation() int { if exif, err := m.GetExifData(); err == nil { return exif.Orientation } else { return 1 } }