2019-12-11 16:55:18 +01:00
|
|
|
package entity
|
2018-07-18 15:17:56 +02:00
|
|
|
|
|
|
|
import (
|
2019-05-14 18:16:35 +02:00
|
|
|
"fmt"
|
2019-05-16 04:03:55 +02:00
|
|
|
"strings"
|
2019-12-27 05:18:52 +01:00
|
|
|
"time"
|
2019-05-14 18:16:35 +02:00
|
|
|
|
|
|
|
"github.com/gosimple/slug"
|
2018-07-18 15:17:56 +02:00
|
|
|
"github.com/jinzhu/gorm"
|
2020-05-25 19:10:44 +02:00
|
|
|
"github.com/photoprism/photoprism/pkg/fs"
|
2020-01-12 14:00:56 +01:00
|
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
2020-05-16 17:07:44 +02:00
|
|
|
"github.com/ulule/deepcopier"
|
2018-07-18 15:17:56 +02:00
|
|
|
)
|
|
|
|
|
2020-06-01 09:45:24 +02:00
|
|
|
type Files []File
|
|
|
|
|
2020-05-27 13:40:21 +02:00
|
|
|
// File represents an image or sidecar file that belongs to a photo.
|
2018-07-18 15:17:56 +02:00
|
|
|
type File struct {
|
2020-05-23 20:58:58 +02:00
|
|
|
ID uint `gorm:"primary_key" json:"-" yaml:"-"`
|
2020-06-09 19:40:32 +02:00
|
|
|
UUID string `gorm:"type:varbinary(42);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"`
|
2020-05-23 20:58:58 +02:00
|
|
|
Photo *Photo `json:"-" yaml:"-"`
|
|
|
|
PhotoID uint `gorm:"index;" json:"-" yaml:"-"`
|
2020-06-09 19:40:32 +02:00
|
|
|
PhotoUID string `gorm:"type:varbinary(42);index;" json:"PhotoUID" yaml:"PhotoUID"`
|
|
|
|
FileUID string `gorm:"type:varbinary(42);unique_index;" json:"UID" yaml:"UID"`
|
2020-05-24 22:16:06 +02:00
|
|
|
FileName string `gorm:"type:varbinary(768);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
|
2020-05-27 13:40:21 +02:00
|
|
|
FileRoot string `gorm:"type:varbinary(16);default:'';unique_index:idx_files_name_root;" json:"Root" yaml:"Root,omitempty"`
|
2020-05-23 20:58:58 +02:00
|
|
|
OriginalName string `gorm:"type:varbinary(768);" json:"OriginalName" yaml:"OriginalName,omitempty"`
|
|
|
|
FileHash string `gorm:"type:varbinary(128);index" json:"Hash" yaml:"Hash,omitempty"`
|
|
|
|
FileSize int64 `json:"Size" yaml:"Size,omitempty"`
|
|
|
|
FileCodec string `gorm:"type:varbinary(32)" json:"Codec" yaml:"Codec,omitempty"`
|
|
|
|
FileType string `gorm:"type:varbinary(32)" json:"Type" yaml:"Type,omitempty"`
|
|
|
|
FileMime string `gorm:"type:varbinary(64)" json:"Mime" yaml:"Mime,omitempty"`
|
|
|
|
FilePrimary bool `json:"Primary" yaml:"Primary,omitempty"`
|
|
|
|
FileSidecar bool `json:"Sidecar" yaml:"Sidecar,omitempty"`
|
|
|
|
FileMissing bool `json:"Missing" yaml:"Missing,omitempty"`
|
|
|
|
FilePortrait bool `json:"Portrait" yaml:"Portrait,omitempty"`
|
|
|
|
FileVideo bool `json:"Video" yaml:"Video,omitempty"`
|
|
|
|
FileDuration time.Duration `json:"Duration" yaml:"Duration,omitempty"`
|
|
|
|
FileWidth int `json:"Width" yaml:"Width,omitempty"`
|
|
|
|
FileHeight int `json:"Height" yaml:"Height,omitempty"`
|
|
|
|
FileOrientation int `json:"Orientation" yaml:"Orientation,omitempty"`
|
2020-07-16 13:02:48 +02:00
|
|
|
FileProjection string `gorm:"type:varbinary(16);" json:"Projection,omitempty" yaml:"Projection,omitempty"`
|
2020-05-23 20:58:58 +02:00
|
|
|
FileAspectRatio float32 `gorm:"type:FLOAT;" json:"AspectRatio" yaml:"AspectRatio,omitempty"`
|
2020-05-24 22:16:06 +02:00
|
|
|
FileMainColor string `gorm:"type:varbinary(16);index;" json:"MainColor" yaml:"MainColor,omitempty"`
|
2020-05-23 20:58:58 +02:00
|
|
|
FileColors string `gorm:"type:varbinary(9);" json:"Colors" yaml:"Colors,omitempty"`
|
|
|
|
FileLuminance string `gorm:"type:varbinary(9);" json:"Luminance" yaml:"Luminance,omitempty"`
|
|
|
|
FileDiff uint32 `json:"Diff" yaml:"Diff,omitempty"`
|
|
|
|
FileChroma uint8 `json:"Chroma" yaml:"Chroma,omitempty"`
|
|
|
|
FileError string `gorm:"type:varbinary(512)" json:"Error" yaml:"Error,omitempty"`
|
2020-07-17 16:09:55 +02:00
|
|
|
ModTime int64 `json:"ModTime" yaml:"-"`
|
2020-05-23 20:58:58 +02:00
|
|
|
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
|
|
|
CreatedIn int64 `json:"CreatedIn" yaml:"-"`
|
|
|
|
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
|
|
|
UpdatedIn int64 `json:"UpdatedIn" yaml:"-"`
|
|
|
|
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
|
2020-07-17 16:09:55 +02:00
|
|
|
Share []FileShare `json:"-" yaml:"-"`
|
|
|
|
Sync []FileSync `json:"-" yaml:"-"`
|
2019-05-14 18:16:35 +02:00
|
|
|
}
|
|
|
|
|
2020-05-16 17:07:44 +02:00
|
|
|
type FileInfos struct {
|
|
|
|
FileWidth int
|
|
|
|
FileHeight int
|
|
|
|
FileOrientation int
|
|
|
|
FileAspectRatio float32
|
|
|
|
FileMainColor string
|
|
|
|
FileColors string
|
|
|
|
FileLuminance string
|
|
|
|
FileDiff uint32
|
|
|
|
FileChroma uint8
|
|
|
|
}
|
|
|
|
|
2020-02-21 01:14:45 +01:00
|
|
|
// FirstFileByHash gets a file in db from its hash
|
2020-04-30 20:07:03 +02:00
|
|
|
func FirstFileByHash(fileHash string) (File, error) {
|
2019-07-03 09:27:30 +02:00
|
|
|
var file File
|
|
|
|
|
2020-04-30 20:07:03 +02:00
|
|
|
q := Db().Unscoped().First(&file, "file_hash = ?", fileHash)
|
2019-07-03 09:27:30 +02:00
|
|
|
|
|
|
|
return file, q.Error
|
|
|
|
}
|
|
|
|
|
2020-07-14 11:00:49 +02:00
|
|
|
// PrimaryFile returns the primary file for a photo uid.
|
|
|
|
func PrimaryFile(photoUID string) (File, error) {
|
|
|
|
var file File
|
|
|
|
|
|
|
|
q := Db().Unscoped().First(&file, "file_primary = 1 AND photo_uid = ?", photoUID)
|
|
|
|
|
|
|
|
return file, q.Error
|
|
|
|
}
|
|
|
|
|
2020-05-23 20:58:58 +02:00
|
|
|
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
|
2019-06-04 18:26:35 +02:00
|
|
|
func (m *File) BeforeCreate(scope *gorm.Scope) error {
|
2020-05-27 13:40:21 +02:00
|
|
|
if rnd.IsUID(m.FileUID, 'f') {
|
2020-05-01 12:57:26 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-05-23 20:58:58 +02:00
|
|
|
return scope.SetColumn("FileUID", rnd.PPID('f'))
|
2019-06-04 18:26:35 +02:00
|
|
|
}
|
|
|
|
|
2020-04-01 12:00:45 +02:00
|
|
|
// ShareFileName returns a meaningful file name useful for sharing.
|
|
|
|
func (m *File) ShareFileName() string {
|
2020-05-25 19:10:44 +02:00
|
|
|
photo := m.RelatedPhoto()
|
2019-05-14 18:16:35 +02:00
|
|
|
|
2020-05-25 19:10:44 +02:00
|
|
|
if photo == nil {
|
|
|
|
return fmt.Sprintf("%s.%s", m.FileHash, m.FileType)
|
|
|
|
} else if len(m.FileHash) < 8 {
|
|
|
|
return fmt.Sprintf("%s.%s", rnd.UUID(), m.FileType)
|
|
|
|
} else if photo.TakenAtLocal.IsZero() || photo.PhotoTitle == "" {
|
|
|
|
return fmt.Sprintf("%s.%s", m.FileHash, m.FileType)
|
2019-05-16 04:03:55 +02:00
|
|
|
}
|
2019-05-14 18:16:35 +02:00
|
|
|
|
2020-05-25 19:10:44 +02:00
|
|
|
name := strings.Title(slug.MakeLang(photo.PhotoTitle, "en"))
|
|
|
|
taken := photo.TakenAtLocal.Format("20060102-150405")
|
2020-04-20 20:07:58 +02:00
|
|
|
token := rnd.Token(3)
|
2019-05-16 04:03:55 +02:00
|
|
|
|
2020-04-20 20:07:58 +02:00
|
|
|
result := fmt.Sprintf("%s-%s-%s.%s", taken, name, token, m.FileType)
|
2019-05-14 18:16:35 +02:00
|
|
|
|
|
|
|
return result
|
2018-07-18 15:17:56 +02:00
|
|
|
}
|
2020-02-01 20:52:28 +01:00
|
|
|
|
2020-03-25 12:39:07 +01:00
|
|
|
// Changed returns true if new and old file size or modified time are different.
|
2020-07-17 16:09:55 +02:00
|
|
|
func (m File) Changed(fileSize int64, modTime time.Time) bool {
|
2020-05-07 21:46:00 +02:00
|
|
|
if m.DeletedAt != nil {
|
2020-05-07 21:55:34 +02:00
|
|
|
return true
|
2020-05-07 21:46:00 +02:00
|
|
|
}
|
2020-05-07 21:55:34 +02:00
|
|
|
|
2020-02-01 20:52:28 +01:00
|
|
|
if m.FileSize != fileSize {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2020-07-17 16:09:55 +02:00
|
|
|
if m.ModTime == modTime.Unix() {
|
2020-04-14 15:08:39 +02:00
|
|
|
return false
|
2020-02-01 20:52:28 +01:00
|
|
|
}
|
|
|
|
|
2020-04-14 15:08:39 +02:00
|
|
|
return true
|
2020-02-01 20:52:28 +01:00
|
|
|
}
|
2020-05-07 19:42:04 +02:00
|
|
|
|
|
|
|
// Purge removes a file from the index by marking it as missing.
|
|
|
|
func (m *File) Purge() error {
|
2020-05-08 09:38:20 +02:00
|
|
|
return Db().Unscoped().Model(m).Updates(map[string]interface{}{"file_missing": true, "file_primary": false}).Error
|
2020-05-07 19:42:04 +02:00
|
|
|
}
|
2020-05-13 15:36:42 +02:00
|
|
|
|
|
|
|
// AllFilesMissing returns true, if all files for the photo of this file are missing.
|
|
|
|
func (m *File) AllFilesMissing() bool {
|
|
|
|
count := 0
|
|
|
|
|
|
|
|
if err := Db().Model(&File{}).
|
2020-05-18 16:36:24 +02:00
|
|
|
Where("photo_id = ? AND file_missing = 0", m.PhotoID).
|
2020-05-13 15:36:42 +02:00
|
|
|
Count(&count).Error; err != nil {
|
2020-05-18 16:36:24 +02:00
|
|
|
log.Errorf("file: %s", err.Error())
|
2020-05-13 15:36:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return count == 0
|
|
|
|
}
|
|
|
|
|
2020-07-06 19:15:57 +02:00
|
|
|
// Create inserts a new row to the database.
|
|
|
|
func (m *File) Create() error {
|
|
|
|
if m.PhotoID == 0 {
|
2020-07-07 10:51:55 +02:00
|
|
|
return fmt.Errorf("file: photo id must not be empty (create)")
|
2020-07-06 19:15:57 +02:00
|
|
|
}
|
|
|
|
|
2020-07-07 10:51:55 +02:00
|
|
|
if err := UnscopedDb().Create(m).Error; err != nil {
|
|
|
|
log.Errorf("file: %s (create)", err)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
2020-07-06 19:15:57 +02:00
|
|
|
}
|
|
|
|
|
2020-05-22 16:29:12 +02:00
|
|
|
// Saves the file in the database.
|
2020-05-13 15:36:42 +02:00
|
|
|
func (m *File) Save() error {
|
2020-05-18 17:24:54 +02:00
|
|
|
if m.PhotoID == 0 {
|
2020-07-07 10:51:55 +02:00
|
|
|
return fmt.Errorf("file: photo id must not be empty (save %s)", m.FileUID)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := UnscopedDb().Save(m).Error; err != nil {
|
|
|
|
log.Errorf("file: %s (save %s)", err, m.FileUID)
|
|
|
|
return err
|
2020-05-18 17:24:54 +02:00
|
|
|
}
|
|
|
|
|
2020-07-07 10:51:55 +02:00
|
|
|
return nil
|
2020-05-13 15:36:42 +02:00
|
|
|
}
|
2020-05-16 17:07:44 +02:00
|
|
|
|
|
|
|
// UpdateVideoInfos updates related video infos based on this file.
|
|
|
|
func (m *File) UpdateVideoInfos() error {
|
|
|
|
values := FileInfos{}
|
|
|
|
|
|
|
|
if err := deepcopier.Copy(&values).From(m); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return Db().Model(File{}).Where("photo_id = ? AND file_video = 1", m.PhotoID).Updates(values).Error
|
|
|
|
}
|
2020-05-25 19:10:44 +02:00
|
|
|
|
2020-05-26 11:00:39 +02:00
|
|
|
// Updates a column in the database.
|
2020-05-25 19:10:44 +02:00
|
|
|
func (m *File) Update(attr string, value interface{}) error {
|
|
|
|
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
|
|
|
}
|
|
|
|
|
|
|
|
// RelatedPhoto returns the related photo entity.
|
|
|
|
func (m *File) RelatedPhoto() *Photo {
|
|
|
|
if m.Photo != nil {
|
|
|
|
return m.Photo
|
|
|
|
}
|
|
|
|
|
|
|
|
photo := Photo{}
|
|
|
|
|
|
|
|
UnscopedDb().Model(m).Related(&photo)
|
|
|
|
|
|
|
|
return &photo
|
|
|
|
}
|
|
|
|
|
|
|
|
// NoJPEG returns true if the file is not a JPEG image file.
|
|
|
|
func (m *File) NoJPEG() bool {
|
|
|
|
return m.FileType != string(fs.TypeJpeg)
|
|
|
|
}
|
2020-06-22 15:16:26 +02:00
|
|
|
|
|
|
|
// Links returns all share links for this entity.
|
|
|
|
func (m *File) Links() Links {
|
|
|
|
return FindLinks("", m.FileUID)
|
|
|
|
}
|
2020-07-16 13:02:48 +02:00
|
|
|
|
|
|
|
// Panorama tests if the file seems to be a panorama image.
|
|
|
|
func (m *File) Panorama() bool {
|
|
|
|
if m.FileSidecar || m.FileWidth <= 1000 || m.FileHeight <= 500 {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2020-07-17 10:10:01 +02:00
|
|
|
return m.FileProjection != ProjectionDefault || (m.FileWidth/m.FileHeight) >= 2
|
2020-07-16 13:02:48 +02:00
|
|
|
}
|