API: Add sort order "random" to find a random set of photos #153

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-01-30 12:27:34 +01:00
parent b2441063ba
commit 47defc861c
14 changed files with 173 additions and 78 deletions

View file

@ -4,9 +4,11 @@ import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/sortby"
)
func TestGetFoldersOriginals(t *testing.T) {
@ -45,7 +47,7 @@ func TestGetFoldersOriginals(t *testing.T) {
for _, folder := range folders {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.MediaUnknown, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, sortby.Name, folder.FolderOrder)
assert.Equal(t, entity.RootOriginals, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
@ -82,7 +84,7 @@ func TestGetFoldersOriginals(t *testing.T) {
for _, folder := range folders {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.MediaUnknown, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, sortby.Name, folder.FolderOrder)
assert.Equal(t, entity.RootOriginals, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
@ -128,7 +130,7 @@ func TestGetFoldersImport(t *testing.T) {
for _, folder := range folders {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.MediaUnknown, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, sortby.Name, folder.FolderOrder)
assert.Equal(t, entity.RootImport, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
@ -165,7 +167,7 @@ func TestGetFoldersImport(t *testing.T) {
for _, folder := range folders {
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, entity.MediaUnknown, folder.FolderType)
assert.Equal(t, entity.SortOrderName, folder.FolderOrder)
assert.Equal(t, sortby.Name, folder.FolderOrder)
assert.Equal(t, entity.RootImport, folder.Root)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)

View file

@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/internal/maps"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/sortby"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -146,7 +147,7 @@ func NewUserAlbum(albumTitle, albumType, userUid string) *Album {
// Set default values.
result := &Album{
AlbumOrder: SortOrderOldest,
AlbumOrder: sortby.Oldest,
AlbumType: albumType,
CreatedAt: now,
UpdatedAt: now,
@ -170,7 +171,7 @@ func NewFolderAlbum(albumTitle, albumPath, albumFilter string) *Album {
now := TimeStamp()
result := &Album{
AlbumOrder: SortOrderAdded,
AlbumOrder: sortby.Added,
AlbumType: AlbumFolder,
AlbumSlug: txt.Clip(albumSlug, txt.ClipSlug),
AlbumPath: txt.Clip(albumPath, txt.ClipPath),
@ -193,7 +194,7 @@ func NewMomentsAlbum(albumTitle, albumSlug, albumFilter string) *Album {
now := TimeStamp()
result := &Album{
AlbumOrder: SortOrderOldest,
AlbumOrder: sortby.Oldest,
AlbumType: AlbumMoment,
AlbumSlug: txt.Clip(albumSlug, txt.ClipSlug),
AlbumFilter: albumFilter,
@ -218,7 +219,7 @@ func NewStateAlbum(albumTitle, albumSlug, albumFilter string) *Album {
now := TimeStamp()
result := &Album{
AlbumOrder: SortOrderNewest,
AlbumOrder: sortby.Newest,
AlbumType: AlbumState,
AlbumSlug: txt.Clip(albumSlug, txt.ClipSlug),
AlbumFilter: albumFilter,
@ -249,7 +250,7 @@ func NewMonthAlbum(albumTitle, albumSlug string, year, month int) *Album {
now := TimeStamp()
result := &Album{
AlbumOrder: SortOrderOldest,
AlbumOrder: sortby.Oldest,
AlbumType: AlbumMonth,
AlbumSlug: albumSlug,
AlbumFilter: f.Serialize(),

View file

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/sortby"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -225,7 +226,7 @@ func TestNewFolderAlbum(t *testing.T) {
assert.Equal(t, "Dogs", album.AlbumTitle)
assert.Equal(t, "dogs", album.AlbumSlug)
assert.Equal(t, AlbumFolder, album.AlbumType)
assert.Equal(t, SortOrderAdded, album.AlbumOrder)
assert.Equal(t, sortby.Added, album.AlbumOrder)
assert.Equal(t, "label:dog", album.AlbumFilter)
})
t.Run("title empty", func(t *testing.T) {
@ -240,7 +241,7 @@ func TestNewMomentsAlbum(t *testing.T) {
assert.Equal(t, "Dogs", album.AlbumTitle)
assert.Equal(t, "dogs", album.AlbumSlug)
assert.Equal(t, AlbumMoment, album.AlbumType)
assert.Equal(t, SortOrderOldest, album.AlbumOrder)
assert.Equal(t, sortby.Oldest, album.AlbumOrder)
assert.Equal(t, "label:dog", album.AlbumFilter)
})
t.Run("title empty", func(t *testing.T) {
@ -255,7 +256,7 @@ func TestNewStateAlbum(t *testing.T) {
assert.Equal(t, "Dogs", album.AlbumTitle)
assert.Equal(t, "dogs", album.AlbumSlug)
assert.Equal(t, AlbumState, album.AlbumType)
assert.Equal(t, SortOrderNewest, album.AlbumOrder)
assert.Equal(t, sortby.Newest, album.AlbumOrder)
assert.Equal(t, "label:dog", album.AlbumFilter)
})
t.Run("title empty", func(t *testing.T) {
@ -270,7 +271,7 @@ func TestNewMonthAlbum(t *testing.T) {
assert.Equal(t, "Dogs", album.AlbumTitle)
assert.Equal(t, "dogs", album.AlbumSlug)
assert.Equal(t, AlbumMonth, album.AlbumType)
assert.Equal(t, SortOrderOldest, album.AlbumOrder)
assert.Equal(t, sortby.Oldest, album.AlbumOrder)
assert.Equal(t, "public:true year:2020 month:7", album.AlbumFilter)
assert.Equal(t, 7, album.AlbumMonth)
assert.Equal(t, 2020, album.AlbumYear)

View file

@ -2,7 +2,6 @@ package entity
import (
"github.com/photoprism/photoprism/pkg/media"
"github.com/sirupsen/logrus"
)
// Default values.
@ -57,36 +56,3 @@ const (
ProviderNone = ""
ProviderPassword = "password"
)
// Sort options.
const (
SortOrderDefault = ""
SortOrderRelevance = "relevance"
SortOrderDuration = "duration"
SortOrderSize = "size"
SortOrderCount = "count"
SortOrderAdded = "added"
SortOrderImported = "imported"
SortOrderEdited = "edited"
SortOrderNewest = "newest"
SortOrderOldest = "oldest"
SortOrderPlace = "place"
SortOrderMoment = "moment"
SortOrderFavorites = "favorites"
SortOrderName = "name"
SortOrderPath = "path"
SortOrderSlug = "slug"
SortOrderCategory = "category"
SortOrderSimilar = "similar"
)
// Log levels.
const (
PanicLevel logrus.Level = iota
FatalLevel
ErrorLevel
WarnLevel
InfoLevel
DebugLevel
TraceLevel
)

View file

@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/sortby"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -83,7 +84,7 @@ func NewFolder(root, pathName string, modTime time.Time) Folder {
Root: root,
Path: pathName,
FolderType: MediaUnknown,
FolderOrder: SortOrderName,
FolderOrder: sortby.Name,
FolderCountry: UnknownCountry.ID,
FolderYear: year,
FolderMonth: month,

View file

@ -4,9 +4,10 @@ import (
"testing"
"time"
"github.com/photoprism/photoprism/internal/form"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/sortby"
)
func TestNewFolder(t *testing.T) {
@ -17,7 +18,7 @@ func TestNewFolder(t *testing.T) {
assert.Equal(t, "May 2020", folder.FolderTitle)
assert.Equal(t, "", folder.FolderDescription)
assert.Equal(t, "", folder.FolderType)
assert.Equal(t, SortOrderName, folder.FolderOrder)
assert.Equal(t, sortby.Name, folder.FolderOrder)
assert.IsType(t, "", folder.FolderUID)
assert.Equal(t, false, folder.FolderFavorite)
assert.Equal(t, false, folder.FolderIgnore)

View file

@ -7,7 +7,9 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/search"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/sortby"
)
// Albums returns a slice of albums.
@ -29,7 +31,7 @@ func AlbumCoverByUID(uid string, public bool) (file entity.File, err error) {
if a, err = AlbumByUID(uid); err != nil {
return file, err
} else if a.AlbumType != entity.AlbumDefault { // TODO: Optimize
f := form.SearchPhotos{Album: a.AlbumUID, Filter: a.AlbumFilter, Order: entity.SortOrderRelevance, Count: 1, Offset: 0, Merged: false}
f := form.SearchPhotos{Album: a.AlbumUID, Filter: a.AlbumFilter, Order: sortby.Relevance, Count: 1, Offset: 0, Merged: false}
if err = f.ParseQueryString(); err != nil {
return file, err

View file

@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/sortby"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -81,35 +82,35 @@ func UserAlbums(f form.SearchAlbums, sess *entity.Session) (results AlbumResults
// Set sort order.
switch f.Order {
case entity.SortOrderCount:
case sortby.Count:
s = s.Order("photo_count DESC, albums.album_title, albums.album_uid DESC")
case entity.SortOrderNewest:
case sortby.Newest:
if f.Type == entity.AlbumDefault || f.Type == entity.AlbumState {
s = s.Order("albums.album_uid DESC")
} else {
s = s.Order("albums.album_year DESC, albums.album_month DESC, albums.album_day DESC, albums.album_title, albums.album_uid DESC")
}
case entity.SortOrderOldest:
case sortby.Oldest:
if f.Type == entity.AlbumDefault || f.Type == entity.AlbumState {
s = s.Order("albums.album_uid ASC")
} else {
s = s.Order("albums.album_year ASC, albums.album_month ASC, albums.album_day ASC, albums.album_title, albums.album_uid ASC")
}
case entity.SortOrderAdded:
case sortby.Added:
s = s.Order("albums.album_uid DESC")
case entity.SortOrderEdited:
case sortby.Edited:
s = s.Order("albums.updated_at DESC, albums.album_uid DESC")
case entity.SortOrderMoment:
case sortby.Moment:
s = s.Order("albums.album_favorite DESC, has_year, albums.album_year DESC, albums.album_month DESC, albums.album_title ASC, albums.album_uid DESC")
case entity.SortOrderPlace:
case sortby.Place:
s = s.Order("albums.album_location, albums.album_title, albums.album_year DESC, albums.album_month ASC, albums.album_day ASC, albums.album_uid DESC")
case entity.SortOrderPath:
case sortby.Path:
s = s.Order("albums.album_path, albums.album_uid DESC")
case entity.SortOrderCategory:
case sortby.Category:
s = s.Order("albums.album_category, albums.album_title, albums.album_uid DESC")
case entity.SortOrderSlug:
case sortby.Slug:
s = s.Order("albums.album_slug ASC, albums.album_uid DESC")
case entity.SortOrderFavorites:
case sortby.Favorites:
if f.Type == entity.AlbumFolder {
s = s.Order("albums.album_favorite DESC, albums.album_path ASC, albums.album_uid DESC")
} else if f.Type == entity.AlbumMonth {
@ -117,7 +118,7 @@ func UserAlbums(f form.SearchAlbums, sess *entity.Session) (results AlbumResults
} else {
s = s.Order("albums.album_favorite DESC, albums.album_title ASC, albums.album_uid DESC")
}
case entity.SortOrderName:
case sortby.Name:
if f.Type == entity.AlbumFolder {
s = s.Order("albums.album_path ASC, albums.album_uid DESC")
} else {

View file

@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/sortby"
"github.com/photoprism/photoprism/pkg/txt"
)
@ -137,28 +138,30 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
// Set sort order.
switch f.Order {
case entity.SortOrderEdited:
case sortby.Edited:
s = s.Where("photos.edited_at IS NOT NULL").Order("photos.edited_at DESC, files.media_id")
case entity.SortOrderRelevance:
case sortby.Relevance:
if f.Label != "" {
s = s.Order("photos.photo_quality DESC, photos_labels.uncertainty ASC, files.time_index")
} else {
s = s.Order("photos.photo_quality DESC, files.time_index")
}
case entity.SortOrderDuration:
case sortby.Duration:
s = s.Order("photos.photo_duration DESC, files.time_index")
case entity.SortOrderSize:
case sortby.Size:
s = s.Order("files.file_size DESC, files.time_index")
case entity.SortOrderNewest:
case sortby.Newest:
s = s.Order("files.time_index")
case entity.SortOrderOldest:
case sortby.Oldest:
s = s.Order("files.photo_taken_at, files.media_id")
case entity.SortOrderSimilar:
case sortby.Similar:
s = s.Where("files.file_diff > 0")
s = s.Order("photos.photo_color, photos.cell_id, files.file_diff, files.time_index")
case entity.SortOrderName:
case sortby.Name:
s = s.Order("photos.photo_path, photos.photo_name, files.time_index")
case entity.SortOrderDefault, entity.SortOrderImported, entity.SortOrderAdded:
case sortby.Random:
s = s.Order(sortby.RandomExpr(s.Dialect()))
case sortby.Default, sortby.Imported, sortby.Added:
s = s.Order("files.media_id")
default:
return PhotoResults{}, 0, ErrBadSortOrder

View file

@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/sortby"
)
func TestPhotos(t *testing.T) {
@ -17,7 +18,7 @@ func TestPhotos(t *testing.T) {
frm.Query = ""
frm.Count = 10
frm.Offset = 0
frm.Order = "duration"
frm.Order = sortby.Duration
photos, _, err := Photos(frm)
if err != nil {
@ -26,6 +27,32 @@ func TestPhotos(t *testing.T) {
assert.LessOrEqual(t, 2, len(photos))
})
t.Run("OrderRandom", func(t *testing.T) {
var frm form.SearchPhotos
frm.Query = ""
frm.Count = 10
frm.Offset = 0
frm.Order = sortby.Random
photos, _, err := Photos(frm)
if err != nil {
t.Fatal(err)
}
assert.LessOrEqual(t, 2, len(photos))
})
t.Run("OrderInvalid", func(t *testing.T) {
var frm form.SearchPhotos
frm.Query = ""
frm.Count = 10
frm.Offset = 0
frm.Order = sortby.Invalid
_, _, err := Photos(frm)
assert.Error(t, err)
})
t.Run("Chinese", func(t *testing.T) {
var frm form.SearchPhotos
@ -855,7 +882,7 @@ func TestPhotos(t *testing.T) {
f.Name = "xxx"
f.Original = "xxyy"
f.Path = "/xxx/xxx/"
f.Order = entity.SortOrderName
f.Order = sortby.Name
photos, _, err := Photos(f)
@ -879,7 +906,7 @@ func TestPhotos(t *testing.T) {
f.Stackable = true
f.Unsorted = true
f.Filter = ""
f.Order = entity.SortOrderAdded
f.Order = sortby.Added
photos, _, err := Photos(f)
@ -895,7 +922,7 @@ func TestPhotos(t *testing.T) {
frm.Query = ""
frm.Count = 10
frm.Offset = 0
frm.Order = entity.SortOrderEdited
frm.Order = sortby.Edited
// Parse query string and filter.
if err := frm.ParseQueryString(); err != nil {

24
pkg/sortby/const.go Normal file
View file

@ -0,0 +1,24 @@
package sortby
const (
Default = ""
Relevance = "relevance"
Duration = "duration"
Size = "size"
Count = "count"
Added = "added"
Imported = "imported"
Edited = "edited"
Newest = "newest"
Oldest = "oldest"
Place = "place"
Moment = "moment"
Favorites = "favorites"
Name = "name"
Path = "path"
Slug = "slug"
Category = "category"
Similar = "similar"
Random = "random"
Invalid = "invalid"
)

24
pkg/sortby/random.go Normal file
View file

@ -0,0 +1,24 @@
package sortby
import (
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/sqlite"
)
const (
MySQL = "mysql"
SQLite3 = "sqlite3"
)
// RandomExpr returns the name of the random function depending on the SQL dialect.
func RandomExpr(dialect gorm.Dialect) *gorm.SqlExpr {
switch dialect.GetName() {
case MySQL:
return gorm.Expr("RAND()")
case SQLite3:
return gorm.Expr("RANDOM()")
default:
return gorm.Expr("RAND()")
}
}

17
pkg/sortby/random_test.go Normal file
View file

@ -0,0 +1,17 @@
package sortby
import (
"testing"
"github.com/jinzhu/gorm"
"github.com/stretchr/testify/assert"
)
func TestRandomExpr(t *testing.T) {
mysql, _ := gorm.GetDialect(MySQL)
sqlite3, _ := gorm.GetDialect(SQLite3)
assert.Equal(t, gorm.Expr("RAND()"), RandomExpr(mysql))
assert.Equal(t, gorm.Expr("RANDOM()"), RandomExpr(sqlite3))
}

25
pkg/sortby/sortorder.go Normal file
View file

@ -0,0 +1,25 @@
/*
Package sortby provides sort order constants and helper functions.
Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package sortby