mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
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:
parent
79654170eb
commit
e5dc335bcf
70 changed files with 2138 additions and 1082 deletions
|
|
@ -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:"-"`
|
||||
|
|
|
|||
|
|
@ -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;"},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;"},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
3
internal/entity/migrate/mysql/20251005-000001.sql
Normal file
3
internal/entity/migrate/mysql/20251005-000001.sql
Normal 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;
|
||||
3
internal/entity/migrate/sqlite3/20251005-000001.sql
Normal file
3
internal/entity/migrate/sqlite3/20251005-000001.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue