Files
photoprism/internal/entity/photo.go
2025-11-21 12:15:59 +01:00

1409 lines
40 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package entity
import (
"errors"
"fmt"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/jinzhu/gorm"
"github.com/ulule/deepcopier"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/react"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/tz"
"github.com/photoprism/photoprism/pkg/txt"
)
const (
PhotoUID = byte('p')
)
var IndexUpdateInterval = 3 * time.Hour // 3 Hours
var MetadataUpdateInterval = 24 * 3 * time.Hour // 3 Days
var MetadataEstimateInterval = 24 * 7 * time.Hour // 7 Days
var photoMutex = sync.Mutex{}
var labelKeywordsSkipSrc = []string{SrcTitle, SrcCaption, SrcSubject, SrcKeyword}
// MapKey builds a deterministic indexing key from the capture timestamp and spatial cell identifier.
func MapKey(takenAt time.Time, cellId string) string {
return path.Join(strconv.FormatInt(takenAt.Unix(), 36), cellId)
}
// Photo represents a photo, all its properties, and link to all its images and sidecar files.
type Photo struct {
ID uint `gorm:"primary_key" yaml:"-"`
UUID string `gorm:"type:VARBINARY(64);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"`
TakenAt time.Time `gorm:"type:DATETIME;index:idx_photos_taken_uid;" json:"TakenAt" yaml:"TakenAt"`
TakenAtLocal time.Time `gorm:"type:DATETIME;" json:"TakenAtLocal" yaml:"TakenAtLocal"`
TakenSrc string `gorm:"type:VARBINARY(8);" json:"TakenSrc" yaml:"TakenSrc,omitempty"`
PhotoUID string `gorm:"type:VARBINARY(42);unique_index;index:idx_photos_taken_uid;" json:"UID" yaml:"UID"`
PhotoType string `gorm:"type:VARBINARY(8);default:'image';" json:"Type" yaml:"Type"`
TypeSrc string `gorm:"type:VARBINARY(8);" json:"TypeSrc" yaml:"TypeSrc,omitempty"`
PhotoTitle string `gorm:"type:VARCHAR(200);" json:"Title" yaml:"Title"`
TitleSrc string `gorm:"type:VARBINARY(8);" json:"TitleSrc" yaml:"TitleSrc,omitempty"`
PhotoCaption string `gorm:"type:VARCHAR(4096);" json:"Caption" yaml:"Caption,omitempty"`
CaptionSrc string `gorm:"type:VARBINARY(8);" json:"CaptionSrc" yaml:"CaptionSrc,omitempty"`
PhotoDescription string `gorm:"-" json:"Description,omitempty" yaml:"Description,omitempty"`
DescriptionSrc string `gorm:"-" json:"DescriptionSrc,omitempty" yaml:"DescriptionSrc,omitempty"`
PhotoPath string `gorm:"type:VARBINARY(1024);index:idx_photos_path_name;" json:"Path" yaml:"-"`
PhotoName string `gorm:"type:VARBINARY(255);index:idx_photos_path_name;" json:"Name" yaml:"-"`
OriginalName string `gorm:"type:VARBINARY(755);" json:"OriginalName" yaml:"OriginalName,omitempty"`
PhotoStack int8 `json:"Stack" yaml:"Stack,omitempty"`
PhotoFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
PhotoPrivate bool `json:"Private" yaml:"Private,omitempty"`
PhotoScan bool `json:"Scan" yaml:"Scan,omitempty"`
PhotoPanorama bool `json:"Panorama" yaml:"Panorama,omitempty"`
TimeZone string `gorm:"type:VARBINARY(64);default:'Local'" json:"TimeZone" yaml:"TimeZone,omitempty"`
PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID" yaml:"-"`
PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc" yaml:"PlaceSrc,omitempty"`
CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"-"`
CellAccuracy int `json:"CellAccuracy" yaml:"CellAccuracy,omitempty"`
PhotoAltitude int `json:"Altitude" yaml:"Altitude,omitempty"`
PhotoLat float64 `gorm:"type:DOUBLE;index;" json:"Lat" yaml:"Lat,omitempty"`
PhotoLng float64 `gorm:"type:DOUBLE;index;" json:"Lng" yaml:"Lng,omitempty"`
PhotoCountry string `gorm:"type:VARBINARY(2);index:idx_photos_country_year_month;default:'zz'" json:"Country" yaml:"-"`
PhotoYear int `gorm:"index:idx_photos_ymd;index:idx_photos_country_year_month;" json:"Year" yaml:"Year"`
PhotoMonth int `gorm:"index:idx_photos_ymd;index:idx_photos_country_year_month;" json:"Month" yaml:"Month"`
PhotoDay int `gorm:"index:idx_photos_ymd" json:"Day" yaml:"Day"`
PhotoIso int `json:"Iso" yaml:"ISO,omitempty"`
PhotoExposure string `gorm:"type:VARBINARY(64);" json:"Exposure" yaml:"Exposure,omitempty"`
PhotoFNumber float32 `gorm:"type:FLOAT;" json:"FNumber" yaml:"FNumber,omitempty"`
PhotoFocalLength int `json:"FocalLength" yaml:"FocalLength,omitempty"`
PhotoQuality int `gorm:"type:SMALLINT" json:"Quality" yaml:"Quality,omitempty"`
PhotoFaces int `json:"Faces,omitempty" yaml:"Faces,omitempty"`
PhotoResolution int `gorm:"type:SMALLINT" json:"Resolution" yaml:"-"`
PhotoDuration time.Duration `json:"Duration,omitempty" yaml:"Duration,omitempty"`
PhotoColor int16 `json:"Color" yaml:"-"`
CameraID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"CameraID" yaml:"-"`
CameraSerial string `gorm:"type:VARBINARY(160);" json:"CameraSerial" yaml:"CameraSerial,omitempty"`
CameraSrc string `gorm:"type:VARBINARY(8);" json:"CameraSrc" yaml:"-"`
LensID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"LensID" yaml:"-"`
Details *Details `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Details" yaml:"Details"`
Camera *Camera `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Camera" yaml:"-"`
Lens *Lens `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Lens" yaml:"-"`
Cell *Cell `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Cell" yaml:"-"`
Place *Place `gorm:"association_autoupdate:false;association_autocreate:false;association_save_reference:false" json:"Place" yaml:"-"`
Keywords []Keyword `json:"-" yaml:"-"`
Albums []Album `json:"Albums" yaml:"-"`
Files []File `yaml:"-"`
Labels []PhotoLabel `yaml:"-"`
CreatedBy string `gorm:"type:VARBINARY(42);index" json:"CreatedBy,omitempty" yaml:"CreatedBy,omitempty"`
CreatedAt time.Time `json:"CreatedAt,omitempty" yaml:"CreatedAt,omitempty"`
UpdatedAt time.Time `json:"UpdatedAt,omitempty" yaml:"UpdatedAt,omitempty"`
EditedAt *time.Time `json:"EditedAt,omitempty" yaml:"EditedAt,omitempty"`
PublishedAt *time.Time `sql:"index" json:"PublishedAt,omitempty" yaml:"PublishedAt,omitempty"`
IndexedAt *time.Time `json:"IndexedAt,omitempty" yaml:"-"`
CheckedAt *time.Time `sql:"index" json:"CheckedAt,omitempty" yaml:"-"`
EstimatedAt *time.Time `json:"EstimatedAt,omitempty" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"DeletedAt,omitempty"`
}
// TableName returns the entity table name.
func (Photo) TableName() string {
return "photos"
}
// NewPhoto returns a Photo with default metadata placeholders and the requested stack flag.
func NewPhoto(stackable bool) Photo {
return NewUserPhoto(stackable, "")
}
// NewUserPhoto returns a Photo initialized for the given user UID, including default Unknown* references and stack state.
func NewUserPhoto(stackable bool, userUid string) Photo {
m := Photo{
PhotoTitle: UnknownTitle,
PhotoType: MediaImage,
PhotoCountry: UnknownCountry.ID,
CameraID: UnknownCamera.ID,
LensID: UnknownLens.ID,
CellID: UnknownLocation.ID,
PlaceID: UnknownPlace.ID,
TimeZone: tz.Local,
Camera: &UnknownCamera,
Lens: &UnknownLens,
Cell: &UnknownLocation,
Place: &UnknownPlace,
CreatedBy: userUid,
}
if stackable {
m.PhotoStack = IsStackable
} else {
m.PhotoStack = IsUnstacked
}
return m
}
// SavePhotoForm merges a photo form submission into the Photo, normalizes data, refreshes derived metadata, and persists the changes.
// The photo must already exist in the database; after saving, derived counters are updated asynchronously.
func SavePhotoForm(m *Photo, form form.Photo) error {
if m == nil {
return fmt.Errorf("photo is nil")
}
locChanged := m.PhotoLat != form.PhotoLat || m.PhotoLng != form.PhotoLng || m.PhotoCountry != form.PhotoCountry
if err := deepcopier.Copy(m).From(form); err != nil {
return err
}
m.NormalizeValues()
if !m.HasID() {
return errors.New("cannot save form when photo id is missing")
}
// Update time fields.
// Batch edit (and other callers) treat TakenAtLocal as a naive timestamp that already includes
// any new Day/Month/Year. Here we normalize it back to UTC using the photo's TimeZone so MariaDB
// (which lacks TZ support) stores a consistent pair of TakenAt / TakenAtLocal values. See
// ComputeDateChange for details on why it returns UTC.
if m.TimeZoneUTC() {
m.TakenAtLocal = m.TakenAt
} else {
m.TakenAt = m.GetTakenAt()
}
m.UpdateDateFields()
details := m.GetDetails()
if form.Details.PhotoID == m.ID {
if err := deepcopier.Copy(details).From(form.Details); err != nil {
return err
}
details.Keywords = strings.Join(txt.UniqueWords(txt.Words(details.Keywords)), ", ")
}
if locChanged && (m.PlaceSrc == SrcManual || m.PlaceSrc == SrcBatch) {
locKeywords, labels := m.UpdateLocation()
m.AddLabels(labels)
w := txt.UniqueWords(txt.Words(details.Keywords))
w = append(w, locKeywords...)
details.Keywords = strings.Join(txt.UniqueWords(w), ", ")
}
if err := m.UpdateLabels(); err != nil {
log.Errorf("photo: %s %s while updating labels", m.String(), err)
}
if err := m.GenerateTitle(m.ClassifyLabels()); err != nil {
log.Info(err)
}
if err := m.IndexKeywords(); err != nil {
log.Errorf("photo: %s %s while indexing keywords", m.String(), err.Error())
}
edited := Now()
m.EditedAt = &edited
m.PhotoQuality = m.QualityScore()
if err := m.Save(); err != nil {
return err
}
// Update precalculated photo and file counts.
UpdateCountsAsync()
return nil
}
// FindPhoto looks up a Photo by UID or numeric ID and preloads key associations used by higher layers.
func FindPhoto(find Photo) *Photo {
if find.PhotoUID == "" && find.ID == 0 {
return nil
}
m := Photo{}
// Preload related entities if a matching record is found.
stmt := UnscopedDb().
Preload("Labels", func(db *gorm.DB) *gorm.DB {
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
}).
Preload("Labels.Label").
Preload("Camera").
Preload("Lens").
Preload("Details").
Preload("Place").
Preload("Cell").
Preload("Cell.Place")
// Find photo by uid.
if rnd.IsUID(find.PhotoUID, PhotoUID) {
if stmt.First(&m, "photo_uid = ?", find.PhotoUID).Error == nil {
return &m
}
}
// Find photo by id.
if find.ID > 0 {
if stmt.First(&m, "id = ?", find.ID).Error == nil {
return &m
}
}
return nil
}
// PhotoLogString returns a sanitized identifier for logging that prefers
// photo name, falling back to original name, UID, or numeric ID.
func PhotoLogString(photoPath, photoName, originalName, photoUID string, id uint) string {
if photoName != "" {
return clean.Log(path.Join(photoPath, photoName))
} else if originalName != "" {
return clean.Log(originalName)
} else if photoUID != "" {
return "uid " + clean.Log(photoUID)
} else if id > 0 {
return fmt.Sprintf("id %d", id)
}
return "*Photo"
}
// String returns the id or name as string for logging purposes.
func (m *Photo) String() string {
if m == nil {
return "Photo<nil>"
}
return PhotoLogString(m.PhotoPath, m.PhotoName, m.OriginalName, m.PhotoUID, m.ID)
}
// FirstOrCreate inserts the Photo if it does not exist and otherwise reloads the persisted row with its associations.
func (m *Photo) FirstOrCreate() *Photo {
if err := m.Create(); err == nil {
return m
} else {
log.Tracef("photo: %s in %s (create)", err, m.String())
}
return FindPhoto(*m)
}
// Create persists a new Photo while holding the package mutex and ensures the related Details record exists.
func (m *Photo) Create() error {
photoMutex.Lock()
defer photoMutex.Unlock()
if err := UnscopedDb().Create(m).Error; err != nil {
return err
}
if err := m.SaveDetails(); err != nil {
return err
}
return nil
}
// Save writes Photo changes, creates missing rows, and re-resolves the primary file relationship.
func (m *Photo) Save() error {
photoMutex.Lock()
defer photoMutex.Unlock()
if err := Save(m, "ID", "PhotoUID"); err != nil {
return err
}
if err := m.SaveDetails(); err != nil {
return err
}
return m.ResolvePrimary()
}
// Update a column in the database.
func (m *Photo) Update(attr string, value interface{}) error {
if m == nil {
return errors.New("photo must not be nil - you may have found a bug")
} else if !m.HasID() {
return errors.New("photo ID must not be empty - you may have found a bug")
}
return UnscopedDb().Model(m).UpdateColumn(attr, value).Error
}
// Updates multiple columns in the database.
func (m *Photo) Updates(values interface{}) error {
if values == nil {
return nil
} else if m == nil {
return errors.New("photo must not be nil - you may have found a bug")
} else if !m.HasID() {
return errors.New("photo ID must not be empty - you may have found a bug")
}
return UnscopedDb().Model(m).UpdateColumns(values).Error
}
// GetID returns the numeric entity ID.
func (m *Photo) GetID() uint {
return m.ID
}
// HasID checks if the photo has an id and uid assigned to it.
func (m *Photo) HasID() bool {
if m == nil {
return false
}
return m.ID > 0 && m.HasUID()
}
// HasUID checks if the photo has a valid UID.
func (m *Photo) HasUID() bool {
if m == nil {
return false
}
return rnd.IsUID(m.PhotoUID, PhotoUID)
}
// GetUID returns the unique entity id.
func (m *Photo) GetUID() string {
if m == nil {
return "<nil>"
}
return m.PhotoUID
}
// MediaType returns the current PhotoType as media.Type.
func (m *Photo) MediaType() media.Type {
return media.Type(m.PhotoType)
}
// ResetMediaType resets the media type and source to the defaults.
func (m *Photo) ResetMediaType(resetSrc string) {
if m.PhotoType != "" && SrcPriority[m.TypeSrc] > SrcPriority[resetSrc] {
return
}
m.PhotoType = MediaImage
m.TypeSrc = SrcAuto
}
// ResetDuration sets the video duration to 0.
func (m *Photo) ResetDuration() {
m.PhotoDuration = 0
}
// HasMediaType checks if the photo has any of the specified media types.
func (m *Photo) HasMediaType(types ...media.Type) bool {
mediaType := m.MediaType()
for _, t := range types {
if mediaType == t {
return true
}
}
return false
}
// SetMediaType sets a new media type if its priority is higher than that of the current type.
func (m *Photo) SetMediaType(newType media.Type, typeSrc string) {
// Only allow a new main media type to be set.
if !newType.IsMain() || newType.Equal(m.PhotoType) {
return
}
// Get current media type.
currentType := m.MediaType()
// Do not change the type if the source priority is lower than the current one.
if SrcPriority[typeSrc] < SrcPriority[m.TypeSrc] && currentType.IsMain() {
return
}
// Do not automatically change a higher priority type to a lower one.
if SrcPriority[typeSrc] <= SrcPriority[SrcFile] && media.Priority[newType] < media.Priority[currentType] {
return
}
// Set new type and type source.
m.PhotoType = newType.String()
m.TypeSrc = typeSrc
// Write a debug log containing the old and new media type.
log.Debugf("photo: changed type of %s from %s to %s", m.String(), currentType.String(), newType.String())
return
}
// Find fetches the matching record.
func (m *Photo) Find() *Photo {
return FindPhoto(*m)
}
// SaveLabels recalculates derived metadata after label edits, persists the Photo, and schedules count updates.
func (m *Photo) SaveLabels() error {
if !m.HasID() {
return errors.New("photo: cannot save to database, id is empty")
}
labels := m.ClassifyLabels()
m.UpdateDateFields()
if err := m.GenerateTitle(labels); err != nil {
log.Info(err)
}
if err := m.IndexKeywords(); err != nil {
log.Errorf("photo: %s", err.Error())
}
m.PhotoQuality = m.QualityScore()
if err := m.Save(); err != nil {
return err
}
// Update precalculated photo and file counts.
UpdateCountsAsync()
return nil
}
// LabelKeywords converts the photo labels (and their categories) into
// keyword tokens that should be indexable for fulltext search. When the
// relation has not been preloaded yet, it fetches the labels transparently
// so callers always receive the same output.
func (m *Photo) LabelKeywords() (result []string) {
if m == nil {
return nil
}
if m.Labels == nil {
m.PreloadLabels()
}
for _, l := range m.Labels {
if l.Label == nil {
continue
}
if l.Uncertainty >= 100 || list.Contains(labelKeywordsSkipSrc, l.LabelSrc) {
continue
}
result = append(result, txt.Keywords(l.Label.LabelName)...)
for _, c := range l.Label.LabelCategories {
if c == nil {
continue
}
result = append(result, txt.Keywords(c.LabelName)...)
}
}
return result
}
// ClassifyLabels converts attached PhotoLabel relations into classify.Labels for downstream AI components.
func (m *Photo) ClassifyLabels() classify.Labels {
result := classify.Labels{}
for _, l := range m.Labels {
if l.Label == nil {
log.Warnf("photo: empty reference while creating classify labels (%d -> %d)", l.PhotoID, l.LabelID)
continue
}
result = append(result, l.ClassifyLabel())
}
return result
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Photo) BeforeCreate(scope *gorm.Scope) error {
if m.TakenAt.IsZero() || m.TakenAtLocal.IsZero() {
now := Now()
if err := scope.SetColumn("TakenAt", now); err != nil {
return err
}
if err := scope.SetColumn("TakenAtLocal", now); err != nil {
return err
}
}
if rnd.IsUnique(m.PhotoUID, PhotoUID) {
return nil
}
m.PhotoUID = rnd.GenerateUID(PhotoUID)
return scope.SetColumn("PhotoUID", m.PhotoUID)
}
// BeforeSave ensures the existence of TakenAt properties before indexing or updating a photo.
func (m *Photo) BeforeSave(scope *gorm.Scope) error {
if m.TakenAt.IsZero() || m.TakenAtLocal.IsZero() {
now := Now()
if err := scope.SetColumn("TakenAt", now); err != nil {
return err
}
if err := scope.SetColumn("TakenAtLocal", now); err != nil {
return err
}
}
return nil
}
// RemoveKeyword removes a word from photo keywords.
func (m *Photo) RemoveKeyword(w string) error {
details := m.GetDetails()
words := txt.RemoveFromWords(txt.Words(details.Keywords), w)
details.Keywords = strings.Join(words, ", ")
return nil
}
// DropKeywords removes the specified keywords from the photo details and then persists them.
func (m *Photo) DropKeywords(remove []string) error {
if m == nil || len(remove) == 0 {
return nil
}
details := m.GetDetails()
original := details.Keywords
words := txt.Words(details.Keywords)
for _, w := range remove {
if w != "" {
words = txt.RemoveFromWords(words, w)
}
}
details.Keywords = strings.Join(words, ", ")
// No update required.
if details.Keywords == original {
return nil
}
return details.Updates(Values{"keywords": details.Keywords})
}
// UpdateLabels refreshes automatically generated labels derived from the title, caption, subject metadata, and keywords.
func (m *Photo) UpdateLabels() error {
if err := m.UpdateTitleLabels(); err != nil {
return err
}
if err := m.UpdateCaptionLabels(); err != nil {
return err
}
if err := m.UpdateSubjectLabels(); err != nil {
return err
}
if err := m.UpdateKeywordLabels(); err != nil {
return err
}
return nil
}
// SubjectNames returns all known subject names.
func (m *Photo) SubjectNames() []string {
if f, err := m.PrimaryFile(); err == nil {
return f.SubjectNames()
}
return nil
}
// SubjectKeywords returns keywords for all known subject names.
func (m *Photo) SubjectKeywords() []string {
return txt.Words(strings.Join(m.SubjectNames(), " "))
}
// UpdateSubjectLabels updates the labels assigned based on photo subject metadata.
func (m *Photo) UpdateSubjectLabels() error {
details := m.GetDetails()
if details == nil {
return nil
} else if details.Subject == "" {
return nil
} else if SrcPriority[details.SubjectSrc] < SrcPriority[SrcMeta] {
return nil
}
keywords := txt.UniqueKeywords(details.Subject)
var labelIds []uint
for _, w := range keywords {
if label, err := FindLabel(w, true); err == nil {
if label.Skip() {
continue
}
labelIds = append(labelIds, label.ID)
FirstOrCreatePhotoLabel(NewPhotoLabel(m.ID, label.ID, 20, classify.SrcSubject))
}
}
return Db().Where("label_src = ? AND photo_id = ? AND label_id NOT IN (?)", classify.SrcSubject, m.ID, labelIds).Delete(&PhotoLabel{}).Error
}
// UpdateKeywordLabels updates the labels assigned based on photo keyword metadata.
func (m *Photo) UpdateKeywordLabels() error {
details := m.GetDetails()
if details == nil {
return nil
} else if details.Keywords == "" {
return nil
}
keywords := txt.UniqueKeywords(details.Keywords)
var labelIds []uint
for _, w := range keywords {
if label, err := FindLabel(w, true); err == nil {
if label.Skip() {
continue
}
labelIds = append(labelIds, label.ID)
FirstOrCreatePhotoLabel(NewPhotoLabel(m.ID, label.ID, 25, classify.SrcKeyword))
}
}
return Db().Where("label_src = ? AND photo_id = ? AND label_id NOT IN (?)", classify.SrcKeyword, m.ID, labelIds).Delete(&PhotoLabel{}).Error
}
// IndexKeywords synchronizes the photo-keyword join table based on normalized keywords from titles, captions, and metadata.
func (m *Photo) IndexKeywords() error {
db := UnscopedDb()
details := m.GetDetails()
var keywordIds []uint
var keywords []string
// Extract keywords from title, caption, and other sources.
keywords = append(keywords, txt.Keywords(m.GetTitle())...)
keywords = append(keywords, txt.Keywords(m.GetCaption())...)
keywords = append(keywords, m.SubjectKeywords()...)
keywords = append(keywords, txt.Words(details.Keywords)...)
keywords = append(keywords, m.LabelKeywords()...)
keywords = append(keywords, txt.Keywords(details.Subject)...)
keywords = append(keywords, txt.Keywords(details.Artist)...)
keywords = txt.UniqueWords(keywords)
for _, w := range keywords {
kw := FirstOrCreateKeyword(NewKeyword(w))
if kw == nil {
log.Errorf("index keyword must not be nil - you may have found a bug")
continue
}
if kw.Skip {
continue
}
keywordIds = append(keywordIds, kw.ID)
FirstOrCreatePhotoKeyword(NewPhotoKeyword(m.ID, kw.ID))
}
return db.Where("photo_id = ? AND keyword_id NOT IN (?)", m.ID, keywordIds).Delete(&PhotoKeyword{}).Error
}
// PreloadFiles loads the non-deleted file records associated with the photo.
func (m *Photo) PreloadFiles() *Photo {
q := Db().
Table("files").
Select("files.*").
Where("files.photo_id = ? AND files.deleted_at IS NULL", m.ID).
Order("files.file_name DESC")
Log("photo", "preload files", q.Scan(&m.Files).Error)
return m
}
// PreloadKeywords loads keyword entities linked to the photo.
func (m *Photo) PreloadKeywords() *Photo {
q := Db().NewScope(nil).DB().
Table("keywords").
Select(`keywords.*`).
Joins("JOIN photos_keywords pk ON pk.keyword_id = keywords.id AND pk.photo_id = ?", m.ID).
Order("keywords.keyword ASC")
Log("photo", "preload files", q.Scan(&m.Keywords).Error)
return m
}
// PreloadAlbums loads albums related to the photo using the standard visibility filters.
func (m *Photo) PreloadAlbums() *Photo {
q := Db().NewScope(nil).DB().
Table("albums").
Select(`albums.*`).
Joins("JOIN photos_albums pa ON pa.album_uid = albums.album_uid AND pa.photo_uid = ? AND pa.hidden = 0", m.PhotoUID).
Where("albums.deleted_at IS NULL").
Order("albums.album_title ASC")
Log("photo", "preload albums", q.Scan(&m.Albums).Error)
return m
}
// PreloadLabels loads labels related to the photo from the database. It is a
// no-op when the Photo pointer is nil or the record has not been persisted yet
// so call sites can invoke it defensively before reading `m.Labels`.
func (m *Photo) PreloadLabels() *Photo {
if m == nil {
return m
} else if !m.HasID() {
return m
}
Log("photo", "preload labels", Db().Model(PhotoLabel{}).Preload("Label").Where("photo_id = ?", m.ID).
Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC").Find(&m.Labels).Error)
return m
}
// PreloadMany loads the primary supporting associations (files, keywords, albums).
func (m *Photo) PreloadMany() *Photo {
m.PreloadFiles()
m.PreloadKeywords()
m.PreloadAlbums()
return m
}
// NormalizeValues updates the model values with the values from deprecated fields, if any.
func (m *Photo) NormalizeValues() (normalized bool) {
if m.PhotoCaption == "" && m.PhotoDescription != "" {
m.PhotoCaption = m.PhotoDescription
m.CaptionSrc = m.DescriptionSrc
m.PhotoDescription = ""
m.DescriptionSrc = ""
normalized = true
}
if timeZone := tz.Name(m.TimeZone); timeZone != m.TimeZone {
m.TimeZone = timeZone
normalized = true
}
return normalized
}
// NoCameraSerial reports whether the photo has no camera serial assigned.
func (m *Photo) NoCameraSerial() bool {
return m.CameraSerial == ""
}
// UnknownCamera tests whether the camera reference is the placeholder entry.
func (m *Photo) UnknownCamera() bool {
return m.CameraID == 0 || m.CameraID == UnknownCamera.ID
}
// UnknownLens tests whether the lens reference is the placeholder entry.
func (m *Photo) UnknownLens() bool {
return m.LensID == 0 || m.LensID == UnknownLens.ID
}
// GetDetails loads or lazily creates the Details record backing optional photo metadata.
func (m *Photo) GetDetails() *Details {
if m.Details != nil {
m.Details.PhotoID = m.ID
return m.Details
} else if !m.HasID() {
m.Details = &Details{}
return m.Details
}
m.Details = &Details{PhotoID: m.ID}
if details := FirstOrCreateDetails(m.Details); details != nil {
m.Details = details
}
return m.Details
}
// SaveDetails writes photo details to the database.
func (m *Photo) SaveDetails() error {
if err := m.GetDetails().Save(); err == nil {
return nil
} else if details := FirstOrCreateDetails(m.GetDetails()); details != nil {
m.Details = details
return nil
} else {
log.Errorf("photo: %s (save details for %d)", err, m.ID)
return err
}
}
// ShouldGenerateLabels reports whether automatic vision labels should be generated for the photo.
// It allows regeneration when forced, when no labels exist, or when only manual/high-uncertainty
// labels are present so low-confidence results do not block improved predictions.
func (m *Photo) ShouldGenerateLabels(force bool) bool {
// Return true if force is set or there are no labels yet.
if len(m.Labels) == 0 || force {
return true
}
// Check if any of the existing labels were generated using a vision model.
for _, l := range m.Labels {
if l.Uncertainty >= 100 {
continue
}
if SrcGenerated[l.LabelSrc] > 0 {
return false
} else if l.LabelSrc == SrcCaption && SrcGenerated[m.CaptionSrc] > 0 {
return false
}
}
return true
}
// AddLabels ensures classify labels exist as Label entities and attaches them to the photo.
// Labels are skipped when they have no usable title or carry 0% probability so that UpdateClassify
// never receives invalid input from upstream detectors.
func (m *Photo) AddLabels(labels classify.Labels) {
for _, classifyLabel := range labels {
title := classifyLabel.Title()
if title == "" || txt.Slug(title) == "" {
log.Debugf("index: skipping blank label (%s)", m)
continue
}
if classifyLabel.Uncertainty >= 100 {
log.Debugf("index: skipping label %s with zero probability (%s)", title, m)
continue
}
labelEntity := FirstOrCreateLabel(NewLabel(title, classifyLabel.Priority))
if labelEntity == nil {
log.Errorf("index: label %s could not be created (%s)", clean.Log(title), m)
continue
}
if labelEntity.Deleted() {
log.Debugf("index: skipping deleted label %s (%s)", clean.Log(title), m)
continue
}
if err := labelEntity.UpdateClassify(classifyLabel); err != nil {
log.Errorf("index: failed to update label %s (%s)", clean.Log(title), err)
}
labelSrc := classifyLabel.Source
if labelSrc == SrcAuto {
labelSrc = SrcImage
} else {
labelSrc = clean.ShortTypeLower(labelSrc)
}
template := NewPhotoLabel(m.ID, labelEntity.ID, classifyLabel.Uncertainty, labelSrc)
template.Topicality = classifyLabel.Topicality
score := 0
if classifyLabel.NSFWConfidence > 0 {
score = classifyLabel.NSFWConfidence
}
if classifyLabel.NSFW && score == 0 {
score = 100
}
if score > 100 {
score = 100
}
template.NSFW = score
photoLabel := FirstOrCreatePhotoLabel(template)
if photoLabel == nil {
log.Errorf("index: photo-label %d must not be nil - you may have found a bug (%s)", labelEntity.ID, m)
continue
}
if photoLabel.HasID() {
updates := Values{}
if photoLabel.Uncertainty > classifyLabel.Uncertainty && photoLabel.Uncertainty < 100 {
updates["Uncertainty"] = classifyLabel.Uncertainty
updates["LabelSrc"] = labelSrc
}
if classifyLabel.Topicality > 0 && photoLabel.Topicality != classifyLabel.Topicality {
updates["Topicality"] = classifyLabel.Topicality
}
if classifyLabel.NSFWConfidence > 0 || classifyLabel.NSFW {
nsfwScore := 0
if classifyLabel.NSFWConfidence > 0 {
nsfwScore = classifyLabel.NSFWConfidence
}
if classifyLabel.NSFW && nsfwScore == 0 {
nsfwScore = 100
}
if nsfwScore > 100 {
nsfwScore = 100
}
if photoLabel.NSFW != nsfwScore {
updates["NSFW"] = nsfwScore
}
}
if len(updates) > 0 {
if err := photoLabel.Updates(updates); err != nil {
log.Errorf("index: %s", err)
}
}
}
}
Db().Set("gorm:auto_preload", true).Model(m).Related(&m.Labels)
}
// SetCamera updates the camera reference if the source priority allows the change.
func (m *Photo) SetCamera(camera *Camera, source string) {
if camera == nil {
log.Warnf("photo: %s failed to update camera from source %s", m.String(), SrcString(source))
return
}
if camera.Unknown() {
return
}
if SrcPriority[source] < SrcPriority[m.CameraSrc] && !m.UnknownCamera() {
return
}
m.CameraID = camera.ID
m.Camera = camera
m.CameraSrc = source
if !m.PhotoScan && m.Camera.Scanner() {
m.PhotoScan = true
}
}
// SetLens updates the lens reference when the source outranks the existing metadata.
func (m *Photo) SetLens(lens *Lens, source string) {
if lens == nil {
log.Warnf("photo: %s failed to update lens from source %s", m.String(), SrcString(source))
return
}
if lens.Unknown() {
return
}
if SrcPriority[source] < SrcPriority[m.CameraSrc] && !m.UnknownLens() {
return
}
m.LensID = lens.ID
m.Lens = lens
}
// SetExposure updates the photo exposure details.
func (m *Photo) SetExposure(focalLength int, fNumber float32, iso int, exposure, source string) {
hasPriority := SrcPriority[source] >= SrcPriority[m.CameraSrc]
// Set focal length.
if focalLength > 0 && focalLength <= 128000 && (hasPriority || m.PhotoFocalLength <= 0) {
m.PhotoFocalLength = focalLength
}
// Set F number.
if fNumber > 0 && fNumber <= 256 && (hasPriority || m.PhotoFNumber <= 0) {
m.PhotoFNumber = fNumber
}
// Set ISO number.
if iso > 0 && iso <= 128000 && (hasPriority || m.PhotoIso <= 0) {
m.PhotoIso = iso
}
// Set exposure time.
if exposure != "" && (hasPriority || m.PhotoExposure == "") {
m.PhotoExposure = exposure
}
}
// AllFilesMissing reports whether all files for this photo are marked missing.
func (m *Photo) AllFilesMissing() bool {
count := 0
if err := Db().Model(&File{}).
Where("photo_id = ? AND file_missing = 0", m.ID).
Count(&count).Error; err != nil {
log.Error(err)
}
return count == 0
}
// AllFiles returns all files of this photo.
func (m *Photo) AllFiles() (files Files) {
if err := UnscopedDb().Where("photo_id = ?", m.ID).Find(&files).Error; err != nil {
log.Error(err)
}
return files
}
// Archive removes the photo from albums and flags it as archived (soft delete).
func (m *Photo) Archive() error {
if !m.HasID() {
return fmt.Errorf("photo has no id")
} else if m.DeletedAt != nil {
return nil
}
deletedAt := Now()
if err := Db().Model(&PhotoAlbum{}).Where("photo_uid = ?", m.PhotoUID).UpdateColumn("hidden", true).Error; err != nil {
return err
} else if err = m.Update("deleted_at", deletedAt); err != nil {
return err
}
m.DeletedAt = &deletedAt
return nil
}
// Restore removes the photo from the archive (reverses soft delete).
func (m *Photo) Restore() error {
if !m.HasID() {
return fmt.Errorf("photo has no id")
} else if m.DeletedAt == nil {
return nil
}
if err := m.Update("deleted_at", gorm.Expr("NULL")); err != nil {
return err
}
m.DeletedAt = nil
return nil
}
// Delete deletes the photo from the index.
func (m *Photo) Delete(permanently bool) (files Files, err error) {
if !m.HasID() {
return files, fmt.Errorf("invalid photo id %d / uid %s", m.ID, clean.Log(m.PhotoUID))
}
if permanently {
return m.DeletePermanently()
}
files = m.AllFiles()
for _, file := range files {
if err = file.Delete(false); err != nil {
log.Errorf("index: %s (remove file)", err)
}
}
m.DeletedAt = TimeStamp()
m.PhotoQuality = -1
return files, m.Updates(Values{"deleted_at": *m.DeletedAt, "photo_quality": m.PhotoQuality})
}
// DeletePermanently permanently removes a photo from the index.
func (m *Photo) DeletePermanently() (files Files, err error) {
if m.ID < 1 || m.PhotoUID == "" {
return files, fmt.Errorf("invalid photo id %d / uid %s", m.ID, clean.Log(m.PhotoUID))
}
files = m.AllFiles()
for _, file := range files {
if logErr := file.DeletePermanently(); logErr != nil {
log.Errorf("index: %s (remove file)", logErr)
}
}
if logErr := UnscopedDb().Delete(Details{}, "photo_id = ?", m.ID).Error; logErr != nil {
log.Errorf("index: %s (remove details)", logErr)
}
if logErr := UnscopedDb().Delete(PhotoKeyword{}, "photo_id = ?", m.ID).Error; logErr != nil {
log.Errorf("index: %s (remove keywords)", logErr)
}
if logErr := UnscopedDb().Delete(PhotoLabel{}, "photo_id = ?", m.ID).Error; logErr != nil {
log.Errorf("index: %s (remove labels)", logErr)
}
if logErr := UnscopedDb().Delete(PhotoAlbum{}, "photo_uid = ?", m.PhotoUID).Error; logErr != nil {
log.Errorf("index: %s (remove albums)", logErr)
}
return files, UnscopedDb().Delete(m).Error
}
// React adds or updates a user reaction.
func (m *Photo) React(user *User, reaction react.Emoji) error {
if user == nil {
return fmt.Errorf("unknown user")
}
if reaction.Unknown() {
return m.UnReact(user)
}
return NewReaction(m.PhotoUID, user.GetUID()).React(reaction).Save()
}
// UnReact deletes a previous user reaction, if any.
func (m *Photo) UnReact(user *User) error {
if user == nil {
return fmt.Errorf("unknown user")
}
if r := FindReaction(m.PhotoUID, user.GetUID()); r != nil {
return r.Delete()
}
return nil
}
// SetFavorite updates the favorite flag of a photo.
func (m *Photo) SetFavorite(favorite bool) error {
changed := m.PhotoFavorite != favorite
m.PhotoFavorite = favorite
m.PhotoQuality = m.QualityScore()
if err := m.Updates(Values{"photo_favorite": m.PhotoFavorite, "photo_quality": m.PhotoQuality}); err != nil {
return err
}
// Update counters if changed and not deleted.
if changed && m.PhotoPrivate == false && m.DeletedAt == nil {
if favorite {
event.Publish("count.favorites", event.Data{
"count": 1,
})
} else {
event.Publish("count.favorites", event.Data{
"count": -1,
})
}
}
return nil
}
// SetStack updates the stack flag of a photo.
func (m *Photo) SetStack(stack int8) {
if m.PhotoStack != stack {
m.PhotoStack = stack
Log("photo", "update stack flag", m.Update("photo_stack", m.PhotoStack))
}
}
// Approved checks if the photo is not in review.
func (m *Photo) Approved() bool {
if !m.HasID() {
return false
} else if m.PhotoQuality >= 3 || m.PhotoType != MediaImage || m.EditedAt != nil {
return true
}
return false
}
// Approve approves the photo if it is in review.
func (m *Photo) Approve() error {
if !m.HasID() {
return fmt.Errorf("photo has no id")
} else if m.PhotoQuality >= 3 {
// Nothing to do.
return nil
}
// Restore photo if archived.
if err := m.Restore(); err != nil {
return err
}
edited := Now()
m.EditedAt = &edited
m.PhotoQuality = m.QualityScore()
if err := Db().Model(m).Updates(Photo{EditedAt: m.EditedAt, PhotoQuality: m.PhotoQuality}).Error; err != nil {
return err
}
// Update precalculated photo and file counts.
UpdateCountsAsync()
event.Publish("count.review", event.Data{
"count": -1,
})
return nil
}
// Links returns all share links for this entity.
func (m *Photo) Links() Links {
return FindLinks("", m.PhotoUID)
}
// PrimaryFile returns the primary file for this photo.
func (m *Photo) PrimaryFile() (*File, error) {
return PrimaryFile(m.PhotoUID)
}
// SetPrimary sets a new primary file.
func (m *Photo) SetPrimary(fileUid string) (err error) {
if m.PhotoUID == "" {
return fmt.Errorf("photo uid is empty")
}
var files []string
if fileUid != "" {
// Do nothing.
} else if err = Db().Model(File{}).
Where("photo_uid = ? AND file_type IN (?) AND file_missing = 0 AND file_error = ''", m.PhotoUID, media.PreviewExpr).
Order("file_width DESC, file_hdr DESC").Limit(1).
Pluck("file_uid", &files).Error; err != nil {
return err
} else if len(files) == 0 {
return fmt.Errorf("found no preview image for %s", clean.Log(m.PhotoUID))
} else {
fileUid = files[0]
}
if fileUid == "" {
return fmt.Errorf("file uid is empty")
}
if err = Db().Model(File{}).
Where("photo_uid = ? AND file_uid <> ?", m.PhotoUID, fileUid).
UpdateColumn("file_primary", 0).Error; err != nil {
return err
} else if err = Db().Model(File{}).Where("photo_uid = ? AND file_uid = ?", m.PhotoUID, fileUid).
UpdateColumn("file_primary", 1).Error; err != nil {
return err
} else if m.PhotoQuality < 0 {
m.PhotoQuality = 0
err = m.UpdateQuality()
}
// Regenerate file search index.
File{PhotoID: m.ID, PhotoUID: m.PhotoUID}.RegenerateIndex()
return nil
}
// MapKey returns a key referencing time and location for indexing.
func (m *Photo) MapKey() string {
return MapKey(m.TakenAt, m.CellID)
}
// SetCameraSerial updates the camera serial number.
func (m *Photo) SetCameraSerial(s string) {
if s = txt.Clip(s, txt.ClipDefault); m.NoCameraSerial() && s != "" {
m.CameraSerial = s
}
}
// FaceCount returns the current number of faces on the primary picture.
func (m *Photo) FaceCount() int {
if f, err := m.PrimaryFile(); err != nil {
return 0
} else {
return f.ValidFaceCount()
}
}
// Indexed returns the immutable timestamp recorded when the photo completed indexing.
// It automatically initializes the timestamp when missing so workers can rely on it even if CheckedAt resets.
func (m *Photo) Indexed() *time.Time {
if m == nil {
return nil
} else if m.IndexedAt == nil {
m.IndexedAt = TimeStamp()
} else if m.IndexedAt.IsZero() {
m.IndexedAt = TimeStamp()
}
return m.IndexedAt
}
// IsNewlyIndexed reports whether the photo still awaits its first indexing timestamp while not being deleted.
func (m *Photo) IsNewlyIndexed() bool {
if m == nil {
return false
} else if m.IndexedAt == nil {
return !m.IsDeleted()
} else if m.IndexedAt.IsZero() {
return !m.IsDeleted()
}
return false
}
// IsDeleted returns true if the photo was deleted.
func (m *Photo) IsDeleted() bool {
if m == nil {
return true
} else if m.DeletedAt == nil {
return false
} else if m.DeletedAt.IsZero() {
return false
}
return true
}