13d32f3294
Signed-off-by: Michael Mayer <michael@photoprism.app>
804 lines
24 KiB
Go
804 lines
24 KiB
Go
package entity
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"math"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize/english"
|
|
"github.com/gosimple/slug"
|
|
"github.com/jinzhu/gorm"
|
|
"github.com/ulule/deepcopier"
|
|
|
|
"github.com/photoprism/photoprism/internal/customize"
|
|
"github.com/photoprism/photoprism/internal/face"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/colors"
|
|
"github.com/photoprism/photoprism/pkg/fs"
|
|
"github.com/photoprism/photoprism/pkg/media"
|
|
"github.com/photoprism/photoprism/pkg/projection"
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
|
"github.com/photoprism/photoprism/pkg/txt"
|
|
)
|
|
|
|
const (
|
|
FileUID = byte('f')
|
|
)
|
|
|
|
// Files represents a file result set.
|
|
type Files []File
|
|
|
|
// Index updates should not run simultaneously.
|
|
var fileIndexMutex = sync.Mutex{}
|
|
var filePrimaryMutex = sync.Mutex{}
|
|
|
|
// File represents an image or sidecar file that belongs to a photo.
|
|
type File struct {
|
|
ID uint `gorm:"primary_key" json:"-" yaml:"-"`
|
|
Photo *Photo `json:"-" yaml:"-"`
|
|
PhotoID uint `gorm:"index:idx_files_photo_id;" json:"-" yaml:"-"`
|
|
PhotoUID string `gorm:"type:VARBINARY(42);index;" json:"PhotoUID" yaml:"PhotoUID"`
|
|
PhotoTakenAt time.Time `gorm:"type:DATETIME;index;" json:"TakenAt" yaml:"TakenAt"`
|
|
TimeIndex *string `gorm:"type:VARBINARY(64);" json:"TimeIndex" yaml:"TimeIndex"`
|
|
MediaID *string `gorm:"type:VARBINARY(32);" json:"MediaID" yaml:"MediaID"`
|
|
MediaUTC int64 `gorm:"column:media_utc;index;" json:"MediaUTC" yaml:"MediaUTC,omitempty"`
|
|
InstanceID string `gorm:"type:VARBINARY(64);index;" json:"InstanceID,omitempty" yaml:"InstanceID,omitempty"`
|
|
FileUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
|
|
FileName string `gorm:"type:VARBINARY(1024);unique_index:idx_files_name_root;" json:"Name" yaml:"Name"`
|
|
FileRoot string `gorm:"type:VARBINARY(16);default:'/';unique_index:idx_files_name_root;" json:"Root" yaml:"Root,omitempty"`
|
|
OriginalName string `gorm:"type:VARBINARY(755);" 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(16)" json:"FileType" yaml:"FileType,omitempty"`
|
|
MediaType string `gorm:"type:VARBINARY(16)" json:"MediaType" yaml:"MediaType,omitempty"`
|
|
FileMime string `gorm:"type:VARBINARY(64)" json:"Mime" yaml:"Mime,omitempty"`
|
|
FilePrimary bool `gorm:"index:idx_files_photo_id;" 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"`
|
|
FileFPS float64 `gorm:"column:file_fps;" json:"FPS" yaml:"FPS,omitempty"`
|
|
FileFrames int `json:"Frames" yaml:"Frames,omitempty"`
|
|
FileWidth int `json:"Width" yaml:"Width,omitempty"`
|
|
FileHeight int `json:"Height" yaml:"Height,omitempty"`
|
|
FileOrientation int `json:"Orientation" yaml:"Orientation,omitempty"`
|
|
FileProjection string `gorm:"type:VARBINARY(64);" json:"Projection,omitempty" yaml:"Projection,omitempty"`
|
|
FileAspectRatio float32 `gorm:"type:FLOAT;" json:"AspectRatio" yaml:"AspectRatio,omitempty"`
|
|
FileHDR bool `gorm:"column:file_hdr;" json:"HDR" yaml:"HDR,omitempty"`
|
|
FileWatermark bool `gorm:"column:file_watermark;" json:"Watermark" yaml:"Watermark,omitempty"`
|
|
FileColorProfile string `gorm:"type:VARBINARY(64);" json:"ColorProfile,omitempty" yaml:"ColorProfile,omitempty"`
|
|
FileMainColor string `gorm:"type:VARBINARY(16);index;" json:"MainColor" yaml:"MainColor,omitempty"`
|
|
FileColors string `gorm:"type:VARBINARY(9);" json:"Colors" yaml:"Colors,omitempty"`
|
|
FileLuminance string `gorm:"type:VARBINARY(9);" json:"Luminance" yaml:"Luminance,omitempty"`
|
|
FileDiff int `json:"Diff" yaml:"Diff,omitempty"`
|
|
FileChroma int16 `json:"Chroma" yaml:"Chroma,omitempty"`
|
|
FileSoftware string `gorm:"type:VARCHAR(64)" json:"Software" yaml:"Software,omitempty"`
|
|
FileError string `gorm:"type:VARBINARY(512)" json:"Error" yaml:"Error,omitempty"`
|
|
ModTime int64 `json:"ModTime" yaml:"-"`
|
|
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
|
|
CreatedIn int64 `json:"CreatedIn" yaml:"-"`
|
|
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
|
|
UpdatedIn int64 `json:"UpdatedIn" yaml:"-"`
|
|
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
|
|
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
|
|
Share []FileShare `json:"-" yaml:"-"`
|
|
Sync []FileSync `json:"-" yaml:"-"`
|
|
markers *Markers
|
|
}
|
|
|
|
// TableName returns the entity table name.
|
|
func (File) TableName() string {
|
|
return "files"
|
|
}
|
|
|
|
// RegenerateIndex updates the search index columns.
|
|
func (m File) RegenerateIndex() {
|
|
fileIndexMutex.Lock()
|
|
defer fileIndexMutex.Unlock()
|
|
|
|
start := time.Now()
|
|
|
|
photosTable := Photo{}.TableName()
|
|
|
|
var updateWhere *gorm.SqlExpr
|
|
var scope string
|
|
|
|
if m.PhotoID > 0 {
|
|
updateWhere = gorm.Expr("files.photo_id = ?", m.PhotoID)
|
|
scope = "index by photo id"
|
|
} else if m.PhotoUID != "" {
|
|
updateWhere = gorm.Expr("files.photo_uid = ?", m.PhotoUID)
|
|
scope = "index by photo uid"
|
|
} else if m.ID > 0 {
|
|
updateWhere = gorm.Expr("files.id = ?", m.ID)
|
|
scope = "index by file id"
|
|
} else {
|
|
updateWhere = gorm.Expr("files.photo_id IS NOT NULL")
|
|
scope = "index"
|
|
}
|
|
|
|
switch DbDialect() {
|
|
case MySQL:
|
|
Log("files", "regenerate photo_taken_at",
|
|
Db().Exec("UPDATE files JOIN ? p ON p.id = files.photo_id SET files.photo_taken_at = p.taken_at_local WHERE ?",
|
|
gorm.Expr(photosTable), updateWhere).Error)
|
|
|
|
Log("files", "regenerate media_id",
|
|
Db().Exec("UPDATE files SET media_id = CASE WHEN file_missing = 0 AND deleted_at IS NULL THEN CONCAT((10000000000 - photo_id), '-', 1 + file_sidecar - file_primary, '-', file_uid) ELSE NULL END WHERE ?",
|
|
updateWhere).Error)
|
|
|
|
Log("files", "regenerate time_index",
|
|
Db().Exec("UPDATE files SET time_index = CASE WHEN media_id IS NOT NULL AND photo_taken_at IS NOT NULL THEN CONCAT(100000000000000 - CAST(photo_taken_at AS UNSIGNED), '-', media_id) ELSE NULL END WHERE ?",
|
|
updateWhere).Error)
|
|
case SQLite3:
|
|
Log("files", "regenerate photo_taken_at",
|
|
Db().Exec("UPDATE files SET photo_taken_at = (SELECT p.taken_at_local FROM ? p WHERE p.id = photo_id) WHERE ?",
|
|
gorm.Expr(photosTable), updateWhere).Error)
|
|
|
|
Log("files", "regenerate media_id",
|
|
Db().Exec("UPDATE files SET media_id = CASE WHEN file_missing = 0 AND deleted_at IS NULL THEN ((10000000000 - photo_id) || '-' || (1 + file_sidecar - file_primary) || '-' || file_uid) ELSE NULL END WHERE ?",
|
|
updateWhere).Error)
|
|
|
|
Log("files", "regenerate time_index",
|
|
Db().Exec("UPDATE files SET time_index = CASE WHEN media_id IS NOT NULL AND photo_taken_at IS NOT NULL THEN ((100000000000000 - strftime('%Y%m%d%H%M%S', photo_taken_at)) || '-' || media_id) ELSE NULL END WHERE ?",
|
|
updateWhere).Error)
|
|
default:
|
|
log.Warnf("sql: unsupported dialect %s", DbDialect())
|
|
}
|
|
|
|
log.Debugf("search: updated %s [%s]", scope, time.Since(start))
|
|
}
|
|
|
|
type FileInfos struct {
|
|
FileWidth int
|
|
FileHeight int
|
|
FileOrientation int
|
|
FileAspectRatio float32
|
|
FileMainColor string
|
|
FileColors string
|
|
FileLuminance string
|
|
FileDiff int
|
|
FileChroma int16
|
|
}
|
|
|
|
// FirstFileByHash gets a file in db from its hash
|
|
func FirstFileByHash(fileHash string) (File, error) {
|
|
var file File
|
|
|
|
res := Db().Unscoped().First(&file, "file_hash = ?", fileHash)
|
|
|
|
return file, res.Error
|
|
}
|
|
|
|
// PrimaryFile returns the primary file for a photo uid.
|
|
func PrimaryFile(photoUid string) (*File, error) {
|
|
file := File{}
|
|
|
|
res := Db().Unscoped().First(&file, "file_primary = 1 AND photo_uid = ?", photoUid)
|
|
|
|
return &file, res.Error
|
|
}
|
|
|
|
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
|
|
func (m *File) BeforeCreate(scope *gorm.Scope) error {
|
|
// Set MediaType based on FileName if empty.
|
|
if m.MediaType == "" && m.FileName != "" {
|
|
m.MediaType = media.FromName(m.FileName).String()
|
|
}
|
|
|
|
// Set MediaUTC based on PhotoTakenAt if empty.
|
|
if m.MediaUTC == 0 && !m.PhotoTakenAt.IsZero() {
|
|
m.MediaUTC = m.PhotoTakenAt.UnixMilli()
|
|
}
|
|
|
|
// Return if uid exists.
|
|
if rnd.IsUnique(m.FileUID, FileUID) {
|
|
return nil
|
|
}
|
|
|
|
return scope.SetColumn("FileUID", rnd.GenerateUID(FileUID))
|
|
}
|
|
|
|
// DownloadName returns the download file name.
|
|
func (m *File) DownloadName(n customize.DownloadName, seq int) string {
|
|
switch n {
|
|
case customize.DownloadNameFile:
|
|
return m.Base(seq)
|
|
case customize.DownloadNameOriginal:
|
|
return m.OriginalBase(seq)
|
|
default:
|
|
return m.ShareBase(seq)
|
|
}
|
|
}
|
|
|
|
// Base returns the file name without path.
|
|
func (m *File) Base(seq int) string {
|
|
if m.FileName == "" {
|
|
return m.ShareBase(seq)
|
|
}
|
|
|
|
base := filepath.Base(m.FileName)
|
|
|
|
if seq > 0 {
|
|
return fmt.Sprintf("%s (%d)%s", fs.StripExt(base), seq, filepath.Ext(base))
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
// OriginalBase returns the original file name without path.
|
|
func (m *File) OriginalBase(seq int) string {
|
|
if m.OriginalName == "" {
|
|
return m.Base(seq)
|
|
}
|
|
|
|
base := filepath.Base(m.OriginalName)
|
|
|
|
if seq > 0 {
|
|
return fmt.Sprintf("%s (%d)%s", fs.StripExt(base), seq, filepath.Ext(base))
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
// ShareBase returns a meaningful file name for sharing.
|
|
func (m *File) ShareBase(seq int) string {
|
|
photo := m.RelatedPhoto()
|
|
|
|
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)
|
|
}
|
|
|
|
name := txt.Title(slug.MakeLang(photo.PhotoTitle, "en"))
|
|
taken := photo.TakenAtLocal.Format("20060102-150405")
|
|
|
|
if seq > 0 {
|
|
return fmt.Sprintf("%s-%s (%d).%s", taken, name, seq, m.FileType)
|
|
}
|
|
|
|
return fmt.Sprintf("%s-%s.%s", taken, name, m.FileType)
|
|
}
|
|
|
|
// Changed returns true if new and old file size or modified time are different.
|
|
func (m File) Changed(fileSize int64, modTime time.Time) bool {
|
|
// File size has changed.
|
|
if m.FileSize != fileSize {
|
|
return true
|
|
}
|
|
|
|
// Modification time has changed.
|
|
if m.ModTime == modTime.UTC().Truncate(time.Second).Unix() {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Missing returns true if this file is current missing or marked as deleted.
|
|
func (m File) Missing() bool {
|
|
return m.FileMissing || m.DeletedAt != nil
|
|
}
|
|
|
|
// DeletePermanently permanently removes a file from the index.
|
|
func (m *File) DeletePermanently() error {
|
|
if m.ID < 1 || m.FileUID == "" {
|
|
return fmt.Errorf("invalid file id %d / uid %s", m.ID, clean.Log(m.FileUID))
|
|
}
|
|
|
|
if err := UnscopedDb().Delete(Marker{}, "file_uid = ?", m.FileUID).Error; err != nil {
|
|
log.Errorf("file %s: %s while removing markers", clean.Log(m.FileUID), err)
|
|
}
|
|
|
|
if err := UnscopedDb().Delete(FileShare{}, "file_id = ?", m.ID).Error; err != nil {
|
|
log.Errorf("file %s: %s while removing share info", clean.Log(m.FileUID), err)
|
|
}
|
|
|
|
if err := UnscopedDb().Delete(FileSync{}, "file_id = ?", m.ID).Error; err != nil {
|
|
log.Errorf("file %s: %s while removing remote sync info", clean.Log(m.FileUID), err)
|
|
}
|
|
|
|
if err := m.ReplaceHash(""); err != nil {
|
|
log.Errorf("file %s: %s while removing covers", clean.Log(m.FileUID), err)
|
|
}
|
|
|
|
return UnscopedDb().Delete(m).Error
|
|
}
|
|
|
|
// ReplaceHash updates file hash references.
|
|
func (m *File) ReplaceHash(newHash string) error {
|
|
if m.FileHash == newHash {
|
|
// Nothing to do.
|
|
return nil
|
|
}
|
|
|
|
// Log values.
|
|
if m.FileHash != "" && newHash == "" {
|
|
log.Tracef("file %s: removing hash %s", clean.Log(m.FileUID), clean.Log(m.FileHash))
|
|
} else if m.FileHash != "" && newHash != "" {
|
|
log.Tracef("file %s: hash %s changed to %s", clean.Log(m.FileUID), clean.Log(m.FileHash), clean.Log(newHash))
|
|
// Reset error when hash changes.
|
|
m.FileError = ""
|
|
}
|
|
|
|
// Set file hash to new value.
|
|
oldHash := m.FileHash
|
|
m.FileHash = newHash
|
|
|
|
// Ok to skip updating related tables?
|
|
if m.NoJPEG() || m.FileHash == "" {
|
|
return nil
|
|
}
|
|
|
|
entities := Tables{
|
|
"albums": Album{},
|
|
"labels": Label{},
|
|
}
|
|
|
|
// Search related tables for references and update them.
|
|
for name, entity := range entities {
|
|
start := time.Now()
|
|
|
|
if res := UnscopedDb().Model(entity).Where("thumb = ?", oldHash).UpdateColumn("thumb", newHash); res.Error != nil {
|
|
return res.Error
|
|
} else if res.RowsAffected > 0 {
|
|
log.Infof("%s: updated %s [%s]", name, english.Plural(int(res.RowsAffected), "cover", "covers"), time.Since(start))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Delete deletes the entity from the database.
|
|
func (m *File) Delete(permanently bool) error {
|
|
if m.ID < 1 || m.FileUID == "" {
|
|
return fmt.Errorf("invalid file id %d / uid %s", m.ID, clean.Log(m.FileUID))
|
|
}
|
|
|
|
if permanently {
|
|
return m.DeletePermanently()
|
|
}
|
|
|
|
return Db().Delete(m).Error
|
|
}
|
|
|
|
// Purge removes a file from the index by marking it as missing.
|
|
func (m *File) Purge() error {
|
|
deletedAt := TimeStamp()
|
|
m.FileMissing = true
|
|
m.FilePrimary = false
|
|
m.DeletedAt = &deletedAt
|
|
return UnscopedDb().Exec("UPDATE files SET file_missing = 1, file_primary = 0, deleted_at = ? WHERE id = ?", &deletedAt, m.ID).Error
|
|
}
|
|
|
|
// Found restores a previously purged file.
|
|
func (m *File) Found() error {
|
|
m.FileMissing = false
|
|
m.DeletedAt = nil
|
|
return UnscopedDb().Exec("UPDATE files SET file_missing = 0, deleted_at = NULL WHERE id = ?", m.ID).Error
|
|
}
|
|
|
|
// 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{}).
|
|
Where("photo_id = ? AND file_missing = 0", m.PhotoID).
|
|
Count(&count).Error; err != nil {
|
|
log.Errorf("file: %s", err.Error())
|
|
}
|
|
|
|
return count == 0
|
|
}
|
|
|
|
// Create inserts a new row to the database.
|
|
func (m *File) Create() error {
|
|
if m.PhotoID == 0 {
|
|
return fmt.Errorf("file: cannot create file with empty photo id")
|
|
}
|
|
|
|
if err := UnscopedDb().Create(m).Error; err != nil {
|
|
log.Errorf("file: %s while saving", err)
|
|
return err
|
|
}
|
|
|
|
if _, err := m.SaveMarkers(); err != nil {
|
|
log.Errorf("file %s: %s while saving markers", clean.Log(m.FileUID), err)
|
|
return err
|
|
}
|
|
|
|
return m.ResolvePrimary()
|
|
}
|
|
|
|
// ResolvePrimary ensures there is only one primary file for a photo.
|
|
func (m *File) ResolvePrimary() (err error) {
|
|
filePrimaryMutex.Lock()
|
|
defer filePrimaryMutex.Unlock()
|
|
|
|
if !m.FilePrimary {
|
|
return nil
|
|
}
|
|
|
|
err = UnscopedDb().
|
|
Exec("UPDATE files SET file_primary = (id = ?) WHERE photo_id = ?", m.ID, m.PhotoID).Error
|
|
|
|
if err == nil {
|
|
m.RegenerateIndex()
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// Save updates the record in the database or inserts a new record if it does not already exist.
|
|
func (m *File) Save() error {
|
|
if m.PhotoID == 0 {
|
|
return fmt.Errorf("file %s: cannot save file with empty photo id", m.FileUID)
|
|
}
|
|
|
|
if err := UnscopedDb().Save(m).Error; err != nil {
|
|
log.Errorf("file %s: %s while saving", clean.Log(m.FileUID), err)
|
|
return err
|
|
}
|
|
|
|
if _, err := m.SaveMarkers(); err != nil {
|
|
log.Errorf("file %s: %s while saving markers", clean.Log(m.FileUID), err)
|
|
return err
|
|
}
|
|
|
|
return m.ResolvePrimary()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Update updates a column in the database.
|
|
func (m *File) Update(attr string, value interface{}) error {
|
|
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
|
|
}
|
|
|
|
// Updates multiple columns in the database.
|
|
func (m *File) Updates(values interface{}) error {
|
|
return UnscopedDb().Model(m).UpdateColumns(values).Error
|
|
}
|
|
|
|
// Rename updates the name and path of this file.
|
|
func (m *File) Rename(fileName, rootName, filePath, fileBase string) error {
|
|
log.Debugf("file %s: renaming %s to %s", clean.Log(m.FileUID), clean.Log(m.FileName), clean.Log(fileName))
|
|
|
|
// Update database row.
|
|
if err := m.Updates(map[string]interface{}{
|
|
"FileName": fileName,
|
|
"FileRoot": rootName,
|
|
"FileMissing": false,
|
|
"DeletedAt": nil,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
m.FileName = fileName
|
|
m.FileRoot = rootName
|
|
m.FileMissing = false
|
|
m.DeletedAt = nil
|
|
|
|
// Update photo path and name if possible.
|
|
if p := m.RelatedPhoto(); p != nil {
|
|
return p.Updates(map[string]interface{}{
|
|
"PhotoPath": filePath,
|
|
"PhotoName": fileBase,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Undelete removes the missing flag from this file.
|
|
func (m *File) Undelete() error {
|
|
if !m.Missing() {
|
|
return nil
|
|
}
|
|
|
|
// Update database row.
|
|
err := m.Updates(map[string]interface{}{
|
|
"FileMissing": false,
|
|
"DeletedAt": nil,
|
|
})
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Debugf("file %s: removed missing flag from %s", clean.Log(m.FileUID), clean.Log(m.FileName))
|
|
|
|
m.FileMissing = false
|
|
m.DeletedAt = nil
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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 fs.ImageJPEG.NotEqual(m.FileType)
|
|
}
|
|
|
|
// Links returns all share links for this entity.
|
|
func (m *File) Links() Links {
|
|
return FindLinks("", m.FileUID)
|
|
}
|
|
|
|
// Panorama checks if the file appears to be a panoramic image.
|
|
func (m *File) Panorama() bool {
|
|
if m.FileSidecar || m.FileWidth <= 1000 || m.FileHeight <= 500 {
|
|
// Too small.
|
|
return false
|
|
} else if m.Projection() != projection.Unknown {
|
|
// Panoramic projection.
|
|
return true
|
|
}
|
|
|
|
// Decide based on aspect ratio.
|
|
return float64(m.FileWidth)/float64(m.FileHeight) > 1.9
|
|
}
|
|
|
|
// Bounds returns the file dimensions as image.Rectangle.
|
|
func (m *File) Bounds() image.Rectangle {
|
|
return image.Rectangle{Min: image.Point{}, Max: image.Point{X: m.FileWidth, Y: m.FileHeight}}
|
|
}
|
|
|
|
// Projection returns the panorama projection name if any.
|
|
func (m *File) Projection() projection.Type {
|
|
return projection.New(m.FileProjection)
|
|
}
|
|
|
|
// SetProjection sets the panorama projection name.
|
|
func (m *File) SetProjection(s string) {
|
|
if s == "" {
|
|
return
|
|
} else if t := projection.New(s); !t.Unknown() {
|
|
m.FileProjection = t.String()
|
|
}
|
|
}
|
|
|
|
// IsHDR returns true if it is a high dynamic range file.
|
|
func (m *File) IsHDR() bool {
|
|
return m.FileHDR
|
|
}
|
|
|
|
// SetHDR sets the high dynamic range flag.
|
|
func (m *File) SetHDR(isHdr bool) {
|
|
if isHdr {
|
|
m.FileHDR = true
|
|
}
|
|
}
|
|
|
|
// ResetHDR removes the high dynamic range flag.
|
|
func (m *File) ResetHDR() {
|
|
m.FileHDR = false
|
|
}
|
|
|
|
// HasWatermark returns true if the file has a watermark.
|
|
func (m *File) HasWatermark() bool {
|
|
return m.FileWatermark
|
|
}
|
|
|
|
// IsAnimated returns true if the file has animated image frames.
|
|
func (m *File) IsAnimated() bool {
|
|
return m.FileFrames > 1 && media.Image.Equal(m.MediaType)
|
|
}
|
|
|
|
// ColorProfile returns the ICC color profile name if any.
|
|
func (m *File) ColorProfile() string {
|
|
return SanitizeStringType(m.FileColorProfile)
|
|
}
|
|
|
|
// HasColorProfile tests if the file has a matching color profile.
|
|
func (m *File) HasColorProfile(profile colors.Profile) bool {
|
|
return profile.Equal(m.FileColorProfile)
|
|
}
|
|
|
|
// SetColorProfile sets the ICC color profile name such as "Display P3".
|
|
func (m *File) SetColorProfile(name string) {
|
|
if name = SanitizeStringType(name); name != "" {
|
|
m.FileColorProfile = SanitizeStringType(name)
|
|
}
|
|
}
|
|
|
|
// ResetColorProfile removes the ICC color profile name.
|
|
func (m *File) ResetColorProfile() {
|
|
m.FileColorProfile = ""
|
|
}
|
|
|
|
// SetSoftware sets the software name.
|
|
func (m *File) SetSoftware(name string) {
|
|
if name = SanitizeStringType(name); name != "" {
|
|
m.FileSoftware = name
|
|
}
|
|
}
|
|
|
|
// SetDuration sets the video/animation duration.
|
|
func (m *File) SetDuration(d time.Duration) {
|
|
if d <= 0 {
|
|
return
|
|
}
|
|
|
|
m.FileDuration = d.Round(time.Second)
|
|
|
|
// Update number of frames.
|
|
if m.FileFrames == 0 && m.FileFPS > 1 {
|
|
m.FileFrames = int(math.Round(m.FileFPS * m.FileDuration.Seconds()))
|
|
}
|
|
|
|
// Update number of frames per second.
|
|
if m.FileFPS == 0 && m.FileFrames > 1 {
|
|
m.FileFPS = float64(m.FileFrames) / m.FileDuration.Seconds()
|
|
}
|
|
}
|
|
|
|
// Bitrate returns the average bitrate in MBit/s if the file has a duration.
|
|
func (m *File) Bitrate() float64 {
|
|
// Make sure size and duration have a positive value.
|
|
if m.FileSize <= 0 || m.FileDuration <= 0 {
|
|
return 0
|
|
}
|
|
|
|
// Divide number of bits through the duration in seconds.
|
|
return ((float64(m.FileSize) * 8) / m.FileDuration.Seconds()) / 1e6
|
|
}
|
|
|
|
// SetFPS sets the average number of frames per second.
|
|
func (m *File) SetFPS(frameRate float64) {
|
|
if frameRate <= 0 {
|
|
return
|
|
}
|
|
|
|
m.FileFPS = frameRate
|
|
|
|
// Update number of frames.
|
|
if m.FileFrames == 0 && m.FileDuration > time.Second {
|
|
m.FileFrames = int(math.Round(m.FileFPS * m.FileDuration.Seconds()))
|
|
}
|
|
}
|
|
|
|
// SetFrames sets the number of video/animation frames.
|
|
func (m *File) SetFrames(n int) {
|
|
if n <= 0 {
|
|
return
|
|
}
|
|
|
|
m.FileFrames = n
|
|
|
|
// Update FPS.
|
|
if m.FileFPS <= 0 && m.FileDuration > 0 {
|
|
m.FileFPS = float64(m.FileFrames) / m.FileDuration.Seconds()
|
|
}
|
|
}
|
|
|
|
// SetMediaUTC sets the media creation date from metadata as unix time in ms.
|
|
func (m *File) SetMediaUTC(taken time.Time) {
|
|
if taken.IsZero() {
|
|
return
|
|
}
|
|
|
|
m.MediaUTC = taken.UTC().UnixMilli()
|
|
}
|
|
|
|
// AddFaces adds face markers to the file.
|
|
func (m *File) AddFaces(faces face.Faces) {
|
|
sort.Slice(faces, func(i, j int) bool {
|
|
return faces[i].Size() > faces[j].Size()
|
|
})
|
|
|
|
for _, f := range faces {
|
|
m.AddFace(f, "")
|
|
}
|
|
}
|
|
|
|
// AddFace adds a face marker to the file.
|
|
func (m *File) AddFace(f face.Face, subjUid string) {
|
|
// Only add faces with exactly one embedding so that they can be compared and clustered.
|
|
if !f.Embeddings.One() {
|
|
return
|
|
}
|
|
|
|
// Create new marker from face.
|
|
marker := NewFaceMarker(f, *m, subjUid)
|
|
|
|
// Failed creating new marker?
|
|
if marker == nil {
|
|
return
|
|
}
|
|
|
|
// Append marker if it doesn't conflict with existing marker.
|
|
if markers := m.Markers(); !markers.Contains(*marker) {
|
|
markers.AppendWithEmbedding(*marker)
|
|
}
|
|
}
|
|
|
|
// ValidFaceCount returns the number of valid face markers.
|
|
func (m *File) ValidFaceCount() (c int) {
|
|
return ValidFaceCount(m.FileUID)
|
|
}
|
|
|
|
// UpdatePhotoFaceCount updates the faces count in the index and returns it if the file is primary.
|
|
func (m *File) UpdatePhotoFaceCount() (c int, err error) {
|
|
// Primary file of an existing photo?
|
|
if !m.FilePrimary || m.PhotoID == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
c = m.ValidFaceCount()
|
|
|
|
err = UnscopedDb().Model(Photo{}).
|
|
Where("id = ?", m.PhotoID).
|
|
UpdateColumn("photo_faces", c).Error
|
|
|
|
return c, err
|
|
}
|
|
|
|
// SaveMarkers updates markers in the index.
|
|
func (m *File) SaveMarkers() (count int, err error) {
|
|
if m.markers == nil {
|
|
return 0, nil
|
|
}
|
|
|
|
return m.markers.Save(m)
|
|
}
|
|
|
|
// Markers finds and returns existing file markers.
|
|
func (m *File) Markers() *Markers {
|
|
if m.markers != nil {
|
|
return m.markers
|
|
} else if m.FileUID == "" {
|
|
m.markers = &Markers{}
|
|
} else if res, err := FindMarkers(m.FileUID); err != nil {
|
|
log.Warnf("file %s: %s while loading markers", clean.Log(m.FileUID), err)
|
|
m.markers = &Markers{}
|
|
} else {
|
|
m.markers = &res
|
|
}
|
|
|
|
return m.markers
|
|
}
|
|
|
|
// UnsavedMarkers tests if any marker hasn't been saved yet.
|
|
func (m *File) UnsavedMarkers() bool {
|
|
if m.markers == nil {
|
|
return false
|
|
}
|
|
|
|
return m.markers.Unsaved()
|
|
}
|
|
|
|
// SubjectNames returns all known subject names.
|
|
func (m *File) SubjectNames() []string {
|
|
return m.Markers().SubjectNames()
|
|
}
|