AI: Include NSFW flag & score when generating labels with Ollama #5232

Related issues: #5233 (reset command), #5234 (schedule for models)

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-10-05 04:23:36 +02:00
parent 79654170eb
commit e5dc335bcf
70 changed files with 2138 additions and 1082 deletions

View file

@ -33,8 +33,9 @@ type Label struct {
LabelSlug string `gorm:"type:VARBINARY(160);unique_index;" json:"Slug" yaml:"-"`
CustomSlug string `gorm:"type:VARBINARY(160);index;" json:"CustomSlug" yaml:"-"`
LabelName string `gorm:"type:VARCHAR(160);" json:"Name" yaml:"Name"`
LabelPriority int `json:"Priority" yaml:"Priority,omitempty"`
LabelFavorite bool `json:"Favorite" yaml:"Favorite,omitempty"`
LabelFavorite bool `gorm:"default:0;" json:"Favorite" yaml:"Favorite,omitempty"`
LabelPriority int `gorm:"default:0;" json:"Priority" yaml:"Priority,omitempty"`
LabelNSFW bool `gorm:"column:label_nsfw;default:0;" json:"NSFW,omitempty" yaml:"NSFW,omitempty"`
LabelDescription string `gorm:"type:VARCHAR(2048);" json:"Description" yaml:"Description,omitempty"`
LabelNotes string `gorm:"type:VARCHAR(1024);" json:"Notes" yaml:"Notes,omitempty"`
LabelCategories []*Label `gorm:"many2many:categories;association_jointable_foreignkey:category_id" json:"-" yaml:"-"`

View file

@ -219,4 +219,10 @@ var DialectMySQL = Migrations{
Stage: "main",
Statements: []string{"UPDATE photos SET time_zone = 'Local' WHERE time_zone = '' OR time_zone IS NULL;"},
},
{
ID: "20251005-000001",
Dialect: "mysql",
Stage: "main",
Statements: []string{"UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;", "UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;", "UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;"},
},
}

View file

@ -135,4 +135,10 @@ var DialectSQLite3 = Migrations{
Stage: "main",
Statements: []string{"UPDATE photos SET time_zone = 'Local' WHERE time_zone = '' OR time_zone IS NULL;"},
},
{
ID: "20251005-000001",
Dialect: "sqlite3",
Stage: "main",
Statements: []string{"UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;", "UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;", "UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;"},
},
}

View file

@ -0,0 +1,3 @@
UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;
UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;
UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;

View file

@ -0,0 +1,3 @@
UPDATE labels SET label_nsfw = 0 WHERE label_nsfw IS NULL;
UPDATE photos_labels SET nsfw = 0 WHERE nsfw IS NULL;
UPDATE photos_labels SET topicality = 0 WHERE topicality IS NULL;

View file

@ -310,23 +310,29 @@ func (m *Photo) SetMediaType(newType media.Type, typeSrc string) {
return
}
// String returns the id or name as string.
// 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>"
}
if m.PhotoName != "" {
return clean.Log(path.Join(m.PhotoPath, m.PhotoName))
} else if m.OriginalName != "" {
return clean.Log(m.OriginalName)
} else if m.PhotoUID != "" {
return "uid " + clean.Log(m.PhotoUID)
} else if m.ID > 0 {
return fmt.Sprintf("id %d", m.ID)
}
return "*Photo"
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.
@ -784,7 +790,6 @@ func (m *Photo) ShouldGenerateLabels(force bool) bool {
// 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) == "" {
@ -823,6 +828,21 @@ func (m *Photo) AddLabels(labels classify.Labels) {
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 {
@ -832,13 +852,32 @@ func (m *Photo) AddLabels(labels classify.Labels) {
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)

View file

@ -12,13 +12,14 @@ type PhotoLabels []PhotoLabel
// PhotoLabel represents the many-to-many relation between Photo and Label.
// Labels are weighted by uncertainty (100 - confidence).
type PhotoLabel struct {
PhotoID uint `gorm:"primary_key;auto_increment:false"`
LabelID uint `gorm:"primary_key;auto_increment:false;index"`
LabelSrc string `gorm:"type:VARBINARY(8);"`
Uncertainty int `gorm:"type:SMALLINT"`
Topicality int `gorm:"type:SMALLINT"`
Photo *Photo `gorm:"PRELOAD:false"`
Label *Label `gorm:"PRELOAD:true"`
PhotoID uint `gorm:"primary_key;auto_increment:false" json:"PhotoID,omitempty" yaml:"PhotoID"`
LabelID uint `gorm:"primary_key;auto_increment:false;index" json:"LabelID,omitempty" yaml:"LabelID"`
LabelSrc string `gorm:"type:VARBINARY(8);" json:"LabelSrc,omitempty" yaml:"LabelSrc,omitempty"`
Uncertainty int `gorm:"type:SMALLINT" json:"Uncertainty" yaml:"Uncertainty"`
Topicality int `gorm:"type:SMALLINT;default:0;" json:"Topicality" yaml:"Topicality,omitempty"`
NSFW int `gorm:"type:SMALLINT;column:nsfw;default:0;" json:"NSFW,omitempty" yaml:"NSFW,omitempty"`
Photo *Photo `gorm:"PRELOAD:false" json:"-" yaml:"-"`
Label *Label `gorm:"PRELOAD:true" json:"Label,omitempty" yaml:"-"`
}
// TableName returns the database table name for PhotoLabel.
@ -145,11 +146,13 @@ func (m *PhotoLabel) ClassifyLabel() classify.Label {
}
result := classify.Label{
Name: m.Label.LabelName,
Source: m.LabelSrc,
Uncertainty: m.Uncertainty,
Topicality: m.Topicality,
Priority: m.Label.LabelPriority,
Name: m.Label.LabelName,
Source: m.LabelSrc,
Uncertainty: m.Uncertainty,
Topicality: m.Topicality,
Priority: m.Label.LabelPriority,
NSFW: m.Label.LabelNSFW,
NSFWConfidence: m.NSFW,
}
return result

View file

@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/tz"
)
@ -597,37 +598,85 @@ func TestPhoto_Delete(t *testing.T) {
func TestPhotos_UIDs(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo1 := &Photo{PhotoUID: "abc123"}
photo2 := &Photo{PhotoUID: "abc456"}
uid1 := rnd.GenerateUID(PhotoUID)
uid2 := rnd.GenerateUID(PhotoUID)
photo1 := &Photo{PhotoUID: uid1}
photo2 := &Photo{PhotoUID: uid2}
photos := Photos{photo1, photo2}
assert.Equal(t, []string{"abc123", "abc456"}, photos.UIDs())
assert.Equal(t, []string{uid1, uid2}, photos.UIDs())
})
}
func TestPhoto_String(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
var m *Photo
assert.Equal(t, "Photo<nil>", m.String())
assert.Equal(t, "Photo<nil>", fmt.Sprintf("%s", m))
})
t.Run("New", func(t *testing.T) {
m := &Photo{PhotoUID: "", PhotoName: "", OriginalName: ""}
assert.Equal(t, "*Photo", m.String())
assert.Equal(t, "*Photo", fmt.Sprintf("%s", m))
})
t.Run("Original", func(t *testing.T) {
m := Photo{PhotoUID: "", PhotoName: "", OriginalName: "holidayOriginal"}
assert.Equal(t, "holidayOriginal", m.String())
})
t.Run("UID", func(t *testing.T) {
m := Photo{PhotoUID: "ps6sg6be2lvl0k53", PhotoName: "", OriginalName: ""}
assert.Equal(t, "uid ps6sg6be2lvl0k53", m.String())
})
generatedUID := rnd.GenerateUID(PhotoUID)
testcases := []struct {
name string
photo *Photo
want string
checkFmt bool
}{
{
name: "Nil",
photo: nil,
want: "Photo<nil>",
checkFmt: true,
},
{
name: "PhotoNameWithPath",
photo: &Photo{PhotoPath: "albums/test", PhotoName: "my photo.jpg"},
want: "'albums/test/my photo.jpg'",
checkFmt: true,
},
{
name: "PhotoNameOnly",
photo: &Photo{PhotoName: "photo.jpg"},
want: "photo.jpg",
},
{
name: "OriginalName",
photo: &Photo{OriginalName: "orig name.dng"},
want: "'orig name.dng'",
},
{
name: "UID",
photo: &Photo{PhotoUID: generatedUID},
want: fmt.Sprintf("uid %s", generatedUID),
},
{
name: "ID",
photo: &Photo{ID: 42},
want: "id 42",
},
{
name: "Fallback",
photo: &Photo{},
want: "*Photo",
checkFmt: true,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
if tc.photo == nil {
var p *Photo
assert.Equal(t, tc.want, p.String())
if tc.checkFmt {
assert.Equal(t, tc.want, fmt.Sprintf("%s", p))
}
return
}
assert.Equal(t, tc.want, tc.photo.String())
if tc.checkFmt {
assert.Equal(t, tc.want, fmt.Sprintf("%s", tc.photo))
}
})
}
}
func TestPhoto_Create(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := Photo{PhotoUID: "567", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
err := photo.Create()
if err != nil {
t.Fatal(err)
@ -637,7 +686,7 @@ func TestPhoto_Create(t *testing.T) {
func TestPhoto_Save(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := Photo{PhotoUID: "567", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
err := photo.Save()
if err != nil {
t.Fatal(err)
@ -893,7 +942,7 @@ func TestPhoto_UpdateKeywordLabels(t *testing.T) {
func TestPhoto_LocationLoaded(t *testing.T) {
t.Run("Photo", func(t *testing.T) {
photo := Photo{PhotoUID: "56798", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
assert.False(t, photo.LocationLoaded())
})
t.Run("PhotoWithCell", func(t *testing.T) {
@ -924,7 +973,7 @@ func TestPhoto_LoadLocation(t *testing.T) {
func TestPhoto_PlaceLoaded(t *testing.T) {
t.Run("False", func(t *testing.T) {
photo := Photo{PhotoUID: "56798", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
photo := Photo{PhotoUID: rnd.GenerateUID(PhotoUID), PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
assert.False(t, photo.PlaceLoaded())
})
}
@ -1129,7 +1178,7 @@ func TestPhoto_SetPrimary(t *testing.T) {
assert.Error(t, err)
})
t.Run("NoPreviewImage", func(t *testing.T) {
m := Photo{PhotoUID: "1245678"}
m := Photo{PhotoUID: rnd.GenerateUID(PhotoUID)}
err := m.SetPrimary("")
assert.Error(t, err)

View file

@ -151,7 +151,7 @@ func UserAlbums(frm form.SearchAlbums, sess *entity.Session) (results AlbumResul
}
}
// Albums with public pictures only?
// Filter private albums.
if frm.Public {
s = s.Where("albums.album_private = 0 AND (albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_private = 0 AND photo_quality > -1 AND deleted_at IS NULL))")
} else {

View file

@ -10,12 +10,16 @@ import (
"github.com/jinzhu/inflection"
)
// Like escapes a string for use in a query.
// Like sanitizes user input so it can be safely interpolated into SQL LIKE
// expressions. It strips operators that we don't expect to persist in the
// statement and lets callers provide their own surrounding wildcards.
func Like(s string) string {
return strings.Trim(clean.SqlString(s), " |&*%")
}
// LikeAny returns a single where condition matching the search words.
// LikeAny builds OR-chained LIKE predicates for a text column. The input string
// may contain AND / OR separators; keywords trigger stemming and plural
// normalization while exact mode disables wildcard suffixes.
func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
if s == "" {
return wheres
@ -73,17 +77,20 @@ func LikeAny(col, s string, keywords, exact bool) (wheres []string) {
return wheres
}
// LikeAnyKeyword returns a single where condition matching the search keywords.
// LikeAnyKeyword is a keyword-optimized wrapper around LikeAny.
func LikeAnyKeyword(col, s string) (wheres []string) {
return LikeAny(col, s, true, false)
}
// LikeAnyWord returns a single where condition matching the search word.
// LikeAnyWord matches whole words and keeps wildcard thresholds tuned for
// free-form text search instead of keyword lists.
func LikeAnyWord(col, s string) (wheres []string) {
return LikeAny(col, s, false, false)
}
// LikeAll returns a list of where conditions matching all search words.
// LikeAll produces AND-chained LIKE predicates for every significant token in
// the search string. When exact is false, longer words receive a suffix
// wildcard to support prefix matches.
func LikeAll(col, s string, keywords, exact bool) (wheres []string) {
if s == "" {
return wheres
@ -117,17 +124,19 @@ func LikeAll(col, s string, keywords, exact bool) (wheres []string) {
return wheres
}
// LikeAllKeywords returns a list of where conditions matching all search keywords.
// LikeAllKeywords is LikeAll specialized for keyword search.
func LikeAllKeywords(col, s string) (wheres []string) {
return LikeAll(col, s, true, false)
}
// LikeAllWords returns a list of where conditions matching all search words.
// LikeAllWords is LikeAll specialized for general word search.
func LikeAllWords(col, s string) (wheres []string) {
return LikeAll(col, s, false, false)
}
// LikeAllNames returns a list of where conditions matching all names.
// LikeAllNames splits a name query into AND-separated groups and generates
// prefix or substring matches against each provided column, keeping multi-word
// tokens intact so "John Doe" still matches full-name columns.
func LikeAllNames(cols Cols, s string) (wheres []string) {
if len(cols) == 0 || len(s) < 1 {
return wheres
@ -160,7 +169,9 @@ func LikeAllNames(cols Cols, s string) (wheres []string) {
return wheres
}
// AnySlug returns a where condition that matches any slug in search.
// AnySlug converts human-friendly search terms into slugs and matches them
// against the provided slug column, including the singularized variant for
// plural words (e.g. "Cats" -> "cat").
func AnySlug(col, search, sep string) (where string) {
if search == "" {
return ""
@ -200,7 +211,8 @@ func AnySlug(col, search, sep string) (where string) {
return strings.Join(wheres, " OR ")
}
// AnyInt returns a where condition that matches any integer within a range.
// AnyInt filters user-specified integers through an allowed range and returns
// an OR-chained equality predicate for the values that remain.
func AnyInt(col, numbers, sep string, min, max int) (where string) {
if numbers == "" {
return ""
@ -234,7 +246,9 @@ func AnyInt(col, numbers, sep string, min, max int) (where string) {
return strings.Join(wheres, " OR ")
}
// OrLike returns a where condition and values for finding multiple terms combined with OR.
// OrLike prepares a parameterised OR/LIKE clause for a single column. Star (* )
// wildcards are mapped to SQL percent wildcards before returning the query and
// bind values.
func OrLike(col, s string) (where string, values []interface{}) {
if txt.Empty(col) || txt.Empty(s) {
return "", []interface{}{}
@ -262,7 +276,9 @@ func OrLike(col, s string) (where string, values []interface{}) {
return where, values
}
// OrLikeCols returns a where condition and values for finding multiple terms combined with OR.
// OrLikeCols behaves like OrLike but fans out the same search terms across
// multiple columns, preserving the order of values so callers can feed them to
// database/sql.
func OrLikeCols(cols []string, s string) (where string, values []interface{}) {
if len(cols) == 0 || txt.Empty(s) {
return "", []interface{}{}
@ -299,12 +315,14 @@ func OrLikeCols(cols []string, s string) (where string, values []interface{}) {
return strings.Join(wheres, " OR "), values
}
// SplitOr splits a search string into separate OR values for an IN condition.
// SplitOr splits a search string on OR separators (|) while respecting escape
// sequences so literals like "\|" survive unchanged.
func SplitOr(s string) (values []string) {
return txt.TrimmedSplitWithEscape(s, txt.OrRune, txt.EscapeRune)
}
// SplitAnd splits a search string into separate AND values.
// SplitAnd splits a search string on AND separators (&) while honouring escape
// sequences.
func SplitAnd(s string) (values []string) {
return txt.TrimmedSplitWithEscape(s, txt.AndRune, txt.EscapeRune)
}

View file

@ -26,6 +26,13 @@ func Labels(frm form.SearchLabels) (results []Label, err error) {
Where("labels.photo_count > 0").
Group("labels.id")
// Filter private labels.
if frm.Public {
s = s.Where("labels.label_nsfw = 0")
} else if frm.NSFW {
s = s.Where("labels.label_nsfw = 1")
}
// Limit result count.
if frm.Count > 0 && frm.Count <= MaxResults {
s = s.Limit(frm.Count).Offset(frm.Offset)

View file

@ -8,16 +8,17 @@ import (
type Label struct {
ID uint `json:"ID"`
LabelUID string `json:"UID"`
Thumb string `json:"Thumb"`
ThumbSrc string `json:"ThumbSrc,omitempty"`
LabelSlug string `json:"Slug"`
CustomSlug string `json:"CustomSlug"`
LabelName string `json:"Name"`
LabelPriority int `json:"Priority"`
LabelFavorite bool `json:"Favorite"`
LabelPriority int `json:"Priority"`
LabelNSFW bool `json:"NSFW,omitempty"`
LabelDescription string `json:"Description"`
LabelNotes string `json:"Notes"`
PhotoCount int `json:"PhotoCount"`
Thumb string `json:"Thumb"`
ThumbSrc string `json:"ThumbSrc,omitempty"`
CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"`
DeletedAt time.Time `json:"DeletedAt,omitempty"`

View file

@ -500,12 +500,6 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
} else {
s = s.Where("photos.deleted_at IS NULL")
if frm.Private {
s = s.Where("photos.photo_private = 1")
} else if frm.Public {
s = s.Where("photos.photo_private = 0")
}
if frm.Review {
s = s.Where("photos.photo_quality < 3")
} else if frm.Quality != 0 && frm.Private == false {
@ -513,6 +507,13 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
}
}
// Filter private pictures.
if frm.Public {
s = s.Where("photos.photo_private = 0")
} else if frm.Private {
s = s.Where("photos.photo_private = 1")
}
// Filter by camera id or name.
if txt.IsPosInt(frm.Camera) {
s = s.Where("photos.camera_id = ?", txt.UInt(frm.Camera))

View file

@ -624,12 +624,6 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
} else {
s = s.Where("photos.deleted_at IS NULL")
if frm.Private {
s = s.Where("photos.photo_private = 1")
} else if frm.Public {
s = s.Where("photos.photo_private = 0")
}
if frm.Review {
s = s.Where("photos.photo_quality < 3")
} else if frm.Quality != 0 && frm.Private == false {
@ -637,6 +631,13 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
}
}
// Filter private pictures.
if frm.Public {
s = s.Where("photos.photo_private = 0")
} else if frm.Private {
s = s.Where("photos.photo_private = 1")
}
// Filter by location code.
if txt.NotEmpty(frm.S2) {
// S2 Cell ID.

View file

@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestGeoResult_Lat(t *testing.T) {
@ -46,12 +47,15 @@ func TestGeoResult_Lng(t *testing.T) {
func TestGeoResults_GeoJSON(t *testing.T) {
taken := time.Date(2000, 1, 1, 1, 1, 1, 1, time.UTC).UTC().Truncate(time.Second)
uid1 := rnd.GenerateUID(entity.PhotoUID)
uid2 := rnd.GenerateUID(entity.PhotoUID)
uid3 := rnd.GenerateUID(entity.PhotoUID)
items := GeoResults{
GeoResult{
ID: "1",
PhotoLat: 7.775,
PhotoLng: 8.775,
PhotoUID: "p1",
PhotoUID: uid1,
PhotoTitle: "Title 1",
PhotoCaption: "Description 1",
PhotoFavorite: false,
@ -65,7 +69,7 @@ func TestGeoResults_GeoJSON(t *testing.T) {
ID: "2",
PhotoLat: 1.775,
PhotoLng: -5.775,
PhotoUID: "p2",
PhotoUID: uid2,
PhotoTitle: "Title 2",
PhotoCaption: "Description 2",
PhotoFavorite: true,
@ -79,7 +83,7 @@ func TestGeoResults_GeoJSON(t *testing.T) {
ID: "3",
PhotoLat: -1.775,
PhotoLng: 100.775,
PhotoUID: "p3",
PhotoUID: uid3,
PhotoTitle: "Title 3",
PhotoCaption: "Description 3",
PhotoFavorite: false,

View file

@ -15,7 +15,8 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// Photo represents a photo search result.
// Photo represents a photo search result row joined with its primary file and
// related metadata that we surface in the UI and API responses.
type Photo struct {
ID uint `json:"-" select:"photos.id"`
CompositeID string `json:"ID" select:"files.photo_id AS composite_id"`
@ -132,7 +133,17 @@ func (m *Photo) GetUID() string {
return m.PhotoUID
}
// Approve approves the photo if it is in review.
// String returns the id or name as string for logging purposes.
func (m *Photo) String() string {
if m == nil {
return "Photo<nil>"
}
return entity.PhotoLogString(m.PhotoPath, m.PhotoName, m.OriginalName, m.PhotoUID, m.ID)
}
// Approve promotes the photo to quality level 3 and clears review flags if it
// currently sits in review state.
func (m *Photo) Approve() error {
if !m.HasID() {
return fmt.Errorf("photo has no id")
@ -172,7 +183,7 @@ func (m *Photo) Approve() error {
return nil
}
// Restore removes the photo from the archive (reverses soft delete).
// Restore removes the photo from the archive by clearing the soft-delete flag.
func (m *Photo) Restore() error {
if !m.HasID() {
return fmt.Errorf("photo has no id")
@ -202,7 +213,8 @@ func (m *Photo) IsPlayable() bool {
}
}
// MediaInfo returns the media file hash and codec depending on the media type.
// MediaInfo returns the best available media hash, codec, mime type, and
// dimensions for the photo based on its media type and merged files.
func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, height int) {
switch m.PhotoType {
case entity.MediaVideo, entity.MediaLive:
@ -247,7 +259,8 @@ func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, hei
return m.FileHash, "", m.FileMime, m.FileWidth, m.FileHeight
}
// ShareBase returns a meaningful file name for sharing.
// ShareBase returns a deterministic, human friendly file name stem for sharing
// downloads generated from the photo's timestamp and title.
func (m *Photo) ShareBase(seq int) string {
var name string
@ -266,9 +279,12 @@ func (m *Photo) ShareBase(seq int) string {
return fmt.Sprintf("%s-%s.%s", taken, name, m.FileType)
}
// PhotoResults represents a list of photo search results that can be post
// processed (for example merged by file).
type PhotoResults []Photo
// Photos returns the result as a slice of Photo.
// Photos returns the results as a slice of the generic PhotoInterface type so
// callers can interact with shared entity helpers.
func (m PhotoResults) Photos() []entity.PhotoInterface {
result := make([]entity.PhotoInterface, len(m))
@ -279,7 +295,7 @@ func (m PhotoResults) Photos() []entity.PhotoInterface {
return result
}
// UIDs returns a slice of photo UIDs.
// UIDs returns the photo UIDs for all results in order.
func (m PhotoResults) UIDs() []string {
result := make([]string, len(m))
@ -290,7 +306,8 @@ func (m PhotoResults) UIDs() []string {
return result
}
// Merge consecutive file results that belong to the same photo.
// Merge collapses consecutive rows that reference the same photo into a single
// item with an aggregated Files slice.
func (m PhotoResults) Merge() (merged PhotoResults, count int, err error) {
count = len(m)
merged = make(PhotoResults, 0, count)

View file

@ -1,6 +1,7 @@
package search
import (
"fmt"
"testing"
"time"
@ -9,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/video"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
@ -28,6 +30,65 @@ func TestPhoto_Ids(t *testing.T) {
assert.Equal(t, "ps6sg6be2lvl0o98", r.GetUID())
}
func TestPhoto_String(t *testing.T) {
testcases := []struct {
name string
photo *Photo
want string
}{
{
name: "Nil",
photo: nil,
want: "Photo<nil>",
},
{
name: "PhotoName",
photo: &Photo{
PhotoPath: "albums/test",
PhotoName: "my photo.jpg",
},
want: "'albums/test/my photo.jpg'",
},
{
name: "OriginalName",
photo: &Photo{
OriginalName: "orig name.dng",
},
want: "'orig name.dng'",
},
{
name: "UID",
photo: &Photo{
PhotoUID: "ps123",
},
want: "uid ps123",
},
{
name: "ID",
photo: &Photo{
ID: 42,
},
want: "id 42",
},
{
name: "Fallback",
photo: &Photo{},
want: "*Photo",
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
if tc.photo == nil {
var p *Photo
assert.Equal(t, tc.want, p.String())
} else {
assert.Equal(t, tc.want, tc.photo.String())
}
})
}
}
func TestPhoto_Approve(t *testing.T) {
t.Run("EmptyPhoto", func(t *testing.T) {
r := Photo{}
@ -395,137 +456,38 @@ func TestPhotoResults_Photos(t *testing.T) {
}
func TestPhotosResults_Merged(t *testing.T) {
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
DeletedAt: &time.Time{},
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "",
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo1",
PhotoYear: 0,
PhotoMonth: 0,
PhotoCountry: "",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
PhotoIso: 0,
PhotoFocalLength: 0,
PhotoFNumber: 0,
PhotoExposure: "",
PhotoQuality: 0,
PhotoResolution: 0,
Merged: false,
CameraID: 0,
CameraModel: "",
CameraMake: "",
CameraType: "",
LensID: 0,
LensModel: "",
LensMake: "",
CellID: "",
PlaceID: "",
PlaceLabel: "",
PlaceCity: "",
PlaceState: "",
PlaceCountry: "",
FileID: 0,
FileUID: "",
FilePrimary: false,
FileMissing: false,
FileName: "",
FileHash: "",
FileType: "",
FileMime: "",
FileWidth: 0,
FileHeight: 0,
FileOrientation: 0,
FileAspectRatio: 0,
FileColors: "",
FileChroma: 0,
FileLuminance: "",
FileDiff: 0,
Files: nil,
}
fileUIDA := rnd.GenerateUID(entity.FileUID)
fileUIDB := rnd.GenerateUID(entity.FileUID)
fileUIDC := rnd.GenerateUID(entity.FileUID)
result2 := Photo{
ID: 22222,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
DeletedAt: &time.Time{},
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "",
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo2",
PhotoYear: 0,
PhotoMonth: 0,
PhotoCountry: "",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
PhotoIso: 0,
PhotoFocalLength: 0,
PhotoFNumber: 0,
PhotoExposure: "",
PhotoQuality: 0,
PhotoResolution: 0,
Merged: false,
CameraID: 0,
CameraModel: "",
CameraMake: "",
CameraType: "",
LensID: 0,
LensModel: "",
LensMake: "",
CellID: "",
PlaceID: "",
PlaceLabel: "",
PlaceCity: "",
PlaceState: "",
PlaceCountry: "",
FileID: 0,
FileUID: "",
FilePrimary: false,
FileMissing: false,
FileName: "",
FileHash: "",
FileType: "",
FileMime: "",
FileWidth: 0,
FileHeight: 0,
FileOrientation: 0,
FileAspectRatio: 0,
FileColors: "",
FileChroma: 0,
FileLuminance: "",
FileDiff: 0,
Files: nil,
results := PhotoResults{
{ID: 1, FileID: 10, FileUID: fileUIDA, FileName: "a.jpg"},
{ID: 1, FileID: 11, FileUID: fileUIDB, FileName: "b.jpg"},
{ID: 2, FileID: 20, FileUID: fileUIDC, FileName: "c.jpg"},
}
results := PhotoResults{result1, result2}
merged, count, err := results.Merge()
assert.NoError(t, err)
assert.Equal(t, 3, count)
assert.Len(t, merged, 2)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 2, count)
t.Log(merged)
first := merged[0]
assert.Equal(t, "1-10", first.CompositeID)
assert.True(t, first.Merged)
assert.Len(t, first.Files, 2)
assert.Equal(t, uint(10), first.Files[0].ID)
assert.Equal(t, uint(11), first.Files[1].ID)
second := merged[1]
assert.Equal(t, "2-20", second.CompositeID)
assert.False(t, second.Merged)
assert.Len(t, second.Files, 1)
assert.Equal(t, uint(20), second.Files[0].ID)
}
func TestPhotosResults_UIDs(t *testing.T) {
uid1 := rnd.GenerateUID(entity.PhotoUID)
uid2 := rnd.GenerateUID(entity.PhotoUID)
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
@ -535,7 +497,7 @@ func TestPhotosResults_UIDs(t *testing.T) {
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "123",
PhotoUID: uid1,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo1",
@ -595,7 +557,7 @@ func TestPhotosResults_UIDs(t *testing.T) {
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "456",
PhotoUID: uid2,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo2",
@ -649,11 +611,12 @@ func TestPhotosResults_UIDs(t *testing.T) {
results := PhotoResults{result1, result2}
result := results.UIDs()
assert.Equal(t, []string{"123", "456"}, result)
assert.Equal(t, []string{uid1, uid2}, result)
}
func TestPhotosResult_ShareFileName(t *testing.T) {
t.Run("WithTitle", func(t *testing.T) {
uid := rnd.GenerateUID(entity.PhotoUID)
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
@ -663,7 +626,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAtLocal: time.Date(2013, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoUID: uid,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "PhotoTitle123",
@ -718,6 +681,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
assert.Contains(t, r, "20131111-090718-Phototitle123")
})
t.Run("NoTitle", func(t *testing.T) {
uid := rnd.GenerateUID(entity.PhotoUID)
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
@ -727,7 +691,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAtLocal: time.Date(2015, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoUID: uid,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "",
@ -779,9 +743,10 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
}
r := result1.ShareBase(0)
assert.Contains(t, r, "20151111-090718-uid123")
assert.Contains(t, r, fmt.Sprintf("20151111-090718-%s", uid))
})
t.Run("SeqGreater0", func(t *testing.T) {
uid := rnd.GenerateUID(entity.PhotoUID)
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
@ -791,7 +756,7 @@ func TestPhotosResult_ShareFileName(t *testing.T) {
TakenAtLocal: time.Date(2022, 11, 11, 9, 7, 18, 0, time.UTC),
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "uid123",
PhotoUID: uid,
PhotoPath: "",
PhotoName: "",
PhotoTitle: "PhotoTitle123",

View file

@ -9,12 +9,15 @@ import (
"github.com/photoprism/photoprism/internal/thumb"
)
// PhotosViewerResults finds photos based on the search form provided and returns them as viewer.Results.
// PhotosViewerResults searches public photos using the provided form and returns
// them in the lightweight viewer format that powers the slideshow endpoints.
func PhotosViewerResults(frm form.SearchPhotos, contentUri, apiUri, previewToken, downloadToken string) (viewer.Results, int, error) {
return UserPhotosViewerResults(frm, nil, contentUri, apiUri, previewToken, downloadToken)
}
// UserPhotosViewerResults finds photos based on the search form and user session and returns them as viewer.Results.
// UserPhotosViewerResults behaves like PhotosViewerResults but also applies the
// permissions encoded in the session (for example shared albums and private
// visibility) before returning viewer-formatted results.
func UserPhotosViewerResults(frm form.SearchPhotos, sess *entity.Session, contentUri, apiUri, previewToken, downloadToken string) (viewer.Results, int, error) {
if results, count, err := searchPhotos(frm, sess, PhotosColsView); err != nil {
return viewer.Results{}, count, err
@ -23,7 +26,9 @@ func UserPhotosViewerResults(frm form.SearchPhotos, sess *entity.Session, conten
}
}
// ViewerResult returns a new photo viewer result.
// ViewerResult converts a photo search result into the DTO consumed by the
// frontend viewer, including derived metadata such as thumbnails and download
// URLs.
func (m *Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken string) viewer.Result {
mediaHash, mediaCodec, mediaMime, width, height := m.MediaInfo()
return viewer.Result{
@ -48,12 +53,12 @@ func (m *Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken str
}
}
// ViewerJSON returns the results as photo viewer JSON.
// ViewerJSON marshals the current result set to the viewer JSON structure.
func (m PhotoResults) ViewerJSON(contentUri, apiUri, previewToken, downloadToken string) ([]byte, error) {
return json.Marshal(m.ViewerResults(contentUri, apiUri, previewToken, downloadToken))
}
// ViewerResults returns the results photo viewer formatted.
// ViewerResults maps every photo into the viewer DTO while preserving order.
func (m PhotoResults) ViewerResults(contentUri, apiUri, previewToken, downloadToken string) (results viewer.Results) {
results = make(viewer.Results, 0, len(m))
@ -64,7 +69,8 @@ func (m PhotoResults) ViewerResults(contentUri, apiUri, previewToken, downloadTo
return results
}
// ViewerResult creates a new photo viewer result.
// ViewerResult converts a geographic search hit into the viewer DTO, reusing
// the thumbnail and download helpers so photos and map results stay aligned.
func (m GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadToken string) viewer.Result {
return viewer.Result{
UID: m.PhotoUID,
@ -88,7 +94,7 @@ func (m GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadToken
}
}
// ViewerJSON returns the results as photo viewer JSON.
// ViewerJSON marshals geo search hits to the viewer JSON structure.
func (photos GeoResults) ViewerJSON(contentUri, apiUri, previewToken, downloadToken string) ([]byte, error) {
results := make(viewer.Results, 0, len(photos))

View file

@ -1,196 +1,190 @@
package search
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/search/viewer"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
func TestPhotoResults_ViewerJSON(t *testing.T) {
result1 := Photo{
ID: 111111,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
DeletedAt: &time.Time{},
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "123",
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo1",
PhotoYear: 0,
PhotoMonth: 0,
PhotoCountry: "",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
PhotoIso: 0,
PhotoFocalLength: 0,
PhotoFNumber: 0,
PhotoExposure: "",
PhotoQuality: 0,
PhotoResolution: 0,
Merged: false,
CameraID: 0,
CameraModel: "",
CameraMake: "",
CameraType: "",
LensID: 0,
LensModel: "",
LensMake: "",
CellID: "",
PlaceID: "",
PlaceLabel: "",
PlaceCity: "",
PlaceState: "",
PlaceCountry: "",
FileID: 0,
FileUID: "",
FilePrimary: false,
FileMissing: false,
FileName: "",
FileHash: "",
FileType: "",
FileMime: "",
FileWidth: 0,
FileHeight: 0,
FileOrientation: 0,
FileAspectRatio: 0,
FileColors: "",
FileChroma: 0,
FileLuminance: "",
FileDiff: 0,
Files: nil,
func TestPhoto_ViewerResult(t *testing.T) {
uid := rnd.GenerateUID(entity.PhotoUID)
imgHash := "img-hash"
videoHash := "video-hash"
taken := time.Date(2024, 5, 1, 15, 4, 5, 0, time.UTC)
photo := Photo{
PhotoUID: uid,
PhotoType: entity.MediaVideo,
PhotoTitle: "Sunset",
PhotoCaption: "Golden hour",
PhotoLat: 12.34,
PhotoLng: 56.78,
TakenAtLocal: taken,
TimeZone: "UTC",
PhotoFavorite: true,
PhotoDuration: 5 * time.Second,
FileHash: imgHash,
FileWidth: 800,
FileHeight: 600,
Files: []entity.File{
{
FileVideo: true,
MediaType: entity.MediaVideo,
FileHash: videoHash,
FileCodec: "avc1",
FileMime: header.ContentTypeMp4AvcMain,
FileWidth: 1920,
FileHeight: 1080,
},
},
}
result2 := Photo{
ID: 22222,
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
DeletedAt: &time.Time{},
TakenAt: time.Time{},
TakenAtLocal: time.Time{},
TakenSrc: "",
TimeZone: "Local",
PhotoUID: "456",
PhotoPath: "",
PhotoName: "",
PhotoTitle: "Photo2",
PhotoYear: 0,
PhotoMonth: 0,
PhotoCountry: "",
PhotoFavorite: false,
PhotoPrivate: false,
PhotoLat: 0,
PhotoLng: 0,
PhotoAltitude: 0,
PhotoIso: 0,
PhotoFocalLength: 0,
PhotoFNumber: 0,
PhotoExposure: "",
PhotoQuality: 0,
PhotoResolution: 0,
Merged: false,
CameraID: 0,
CameraModel: "",
CameraMake: "",
CameraType: "",
LensID: 0,
LensModel: "",
LensMake: "",
CellID: "",
PlaceID: "",
PlaceLabel: "",
PlaceCity: "",
PlaceState: "",
PlaceCountry: "",
FileID: 0,
FileUID: "",
FilePrimary: false,
FileMissing: false,
FileName: "",
FileHash: "",
FileType: "",
FileMime: "",
FileWidth: 0,
FileHeight: 0,
FileOrientation: 0,
FileAspectRatio: 0,
FileColors: "",
FileChroma: 0,
FileLuminance: "",
FileDiff: 0,
Files: nil,
result := photo.ViewerResult("/content", "/api/v1", "preview-token", "download-token")
assert.Equal(t, uid, result.UID)
assert.Equal(t, entity.MediaVideo, result.Type)
assert.Equal(t, "Sunset", result.Title)
assert.Equal(t, "Golden hour", result.Caption)
assert.Equal(t, 12.34, result.Lat)
assert.Equal(t, 56.78, result.Lng)
assert.Equal(t, taken, result.TakenAtLocal)
assert.Equal(t, "UTC", result.TimeZone)
assert.True(t, result.Favorite)
assert.True(t, result.Playable)
assert.Equal(t, 5*time.Second, result.Duration)
assert.Equal(t, videoHash, result.Hash)
assert.Equal(t, "avc1", result.Codec)
assert.Equal(t, header.ContentTypeMp4AvcMain, result.Mime)
assert.Equal(t, 1920, result.Width)
assert.Equal(t, 1080, result.Height)
if assert.NotNil(t, result.Thumbs) {
assert.NotNil(t, result.Thumbs.Fit720)
}
assert.Equal(t, "/api/v1/dl/img-hash?t=download-token", result.DownloadUrl)
}
func TestPhotoResults_ViewerFormatting(t *testing.T) {
uid1 := rnd.GenerateUID(entity.PhotoUID)
uid2 := rnd.GenerateUID(entity.PhotoUID)
photos := PhotoResults{
{PhotoUID: uid1},
{PhotoUID: uid2},
}
results := PhotoResults{result1, result2}
b, err := results.ViewerJSON("/content", "/api/v1", "preview-token", "download-token")
results := photos.ViewerResults("/content", "/api", "preview", "download")
assert.Len(t, results, 2)
assert.Equal(t, uid1, results[0].UID)
assert.Equal(t, uid2, results[1].UID)
data, err := photos.ViewerJSON("/content", "/api", "preview", "download")
if err != nil {
t.Fatal(err)
t.Fatalf("unexpected error: %v", err)
}
t.Logf("result: %s", b)
var parsed viewer.Results
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal viewer json: %v", err)
}
assert.Len(t, parsed, 2)
assert.Equal(t, uid1, parsed[0].UID)
assert.Equal(t, uid2, parsed[1].UID)
}
func TestGeoResult_ViewerResult(t *testing.T) {
uid := rnd.GenerateUID(entity.PhotoUID)
taken := time.Date(2023, 3, 14, 9, 26, 53, 0, time.UTC)
geo := GeoResult{
PhotoUID: uid,
PhotoType: entity.MediaImage,
PhotoTitle: "Mountains",
PhotoCaption: "Snow peaks",
PhotoLat: -12.34,
PhotoLng: 78.9,
TakenAtLocal: taken,
TimeZone: "Europe/Berlin",
PhotoFavorite: false,
PhotoDuration: 0,
FileHash: "img-hash",
FileCodec: "jpeg",
FileMime: header.ContentTypeJpeg,
FileWidth: 1024,
FileHeight: 768,
}
result := geo.ViewerResult("/content", "/api", "preview", "download")
assert.Equal(t, uid, result.UID)
assert.Equal(t, entity.MediaImage, result.Type)
assert.Equal(t, "Mountains", result.Title)
assert.Equal(t, "Snow peaks", result.Caption)
assert.Equal(t, -12.34, result.Lat)
assert.Equal(t, 78.9, result.Lng)
assert.Equal(t, taken, result.TakenAtLocal)
assert.Equal(t, "Europe/Berlin", result.TimeZone)
assert.False(t, result.Favorite)
assert.False(t, result.Playable)
assert.Equal(t, "img-hash", result.Hash)
assert.Equal(t, "jpeg", result.Codec)
assert.Equal(t, header.ContentTypeJpeg, result.Mime)
assert.Equal(t, 1024, result.Width)
assert.Equal(t, 768, result.Height)
if assert.NotNil(t, result.Thumbs) {
assert.NotNil(t, result.Thumbs.Fit720)
}
assert.Equal(t, "/api/dl/img-hash?t=download", result.DownloadUrl)
}
func TestGeoResults_ViewerJSON(t *testing.T) {
taken := time.Date(2000, 1, 1, 1, 1, 1, 1, time.UTC).UTC().Truncate(time.Second)
uid1 := rnd.GenerateUID(entity.PhotoUID)
uid2 := rnd.GenerateUID(entity.PhotoUID)
items := GeoResults{
GeoResult{
ID: "1",
PhotoLat: 7.775,
PhotoLng: 8.775,
PhotoUID: "p1",
PhotoTitle: "Title 1",
PhotoCaption: "Description 1",
PhotoFavorite: false,
PhotoType: entity.MediaVideo,
FileHash: "d2b4a5d18276f96f1b5a1bf17fd82d6fab3807f2",
FileWidth: 1920,
FileHeight: 1080,
TakenAtLocal: taken,
},
GeoResult{
ID: "2",
PhotoLat: 1.775,
PhotoLng: -5.775,
PhotoUID: "p2",
PhotoTitle: "Title 2",
PhotoCaption: "Description 2",
PhotoFavorite: true,
PhotoType: entity.MediaImage,
FileHash: "da639e836dfa9179e66c619499b0a5e592f72fc1",
FileWidth: 3024,
FileHeight: 3024,
TakenAtLocal: taken,
},
GeoResult{
ID: "3",
PhotoLat: -1.775,
PhotoLng: 100.775,
PhotoUID: "p3",
PhotoTitle: "Title 3",
PhotoCaption: "Description 3",
PhotoFavorite: false,
PhotoType: entity.MediaRaw,
FileHash: "412fe4c157a82b636efebc5bc4bc4a15c321aad1",
FileWidth: 5000,
FileHeight: 10000,
TakenAtLocal: taken,
},
{PhotoUID: uid1, FileHash: "hash1"},
{PhotoUID: uid2, FileHash: "hash2"},
}
b, err := items.ViewerJSON("/content", "/api/v1", "preview-token", "download-token")
data, err := items.ViewerJSON("/content", "/api", "preview", "download")
if err != nil {
t.Fatal(err)
t.Fatalf("unexpected error: %v", err)
}
t.Logf("result: %s", b)
var parsed viewer.Results
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal viewer json: %v", err)
}
assert.Len(t, parsed, 2)
assert.Equal(t, uid1, parsed[0].UID)
assert.Equal(t, uid2, parsed[1].UID)
}
func TestPhotosViewerResults(t *testing.T) {
fixture := entity.PhotoFixtures.Get("19800101_000002_D640C559")
form := form.SearchPhotos{
UID: fixture.PhotoUID,
Count: 1,
Primary: true,
}
results, count, err := PhotosViewerResults(form, "/content", "/api", "preview", "download")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assert.Greater(t, count, 0)
if assert.NotEmpty(t, results) {
assert.Equal(t, fixture.PhotoUID, results[0].UID)
assert.NotNil(t, results[0].Thumbs)
}
}