From 47defc861c17ca1348eb0193b9ea84ce2708f228 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Mon, 30 Jan 2023 12:27:34 +0100 Subject: [PATCH] API: Add sort order "random" to find a random set of photos #153 Signed-off-by: Michael Mayer --- internal/api/folders_search_test.go | 12 +++++----- internal/entity/album.go | 11 ++++----- internal/entity/album_test.go | 9 ++++---- internal/entity/entity_const.go | 34 ---------------------------- internal/entity/folder.go | 3 ++- internal/entity/folder_test.go | 7 +++--- internal/query/albums.go | 4 +++- internal/search/albums.go | 25 +++++++++++---------- internal/search/photos.go | 21 +++++++++-------- internal/search/photos_test.go | 35 +++++++++++++++++++++++++---- pkg/sortby/const.go | 24 ++++++++++++++++++++ pkg/sortby/random.go | 24 ++++++++++++++++++++ pkg/sortby/random_test.go | 17 ++++++++++++++ pkg/sortby/sortorder.go | 25 +++++++++++++++++++++ 14 files changed, 173 insertions(+), 78 deletions(-) create mode 100644 pkg/sortby/const.go create mode 100644 pkg/sortby/random.go create mode 100644 pkg/sortby/random_test.go create mode 100644 pkg/sortby/sortorder.go diff --git a/internal/api/folders_search_test.go b/internal/api/folders_search_test.go index 3f78dd000..2489fdf07 100644 --- a/internal/api/folders_search_test.go +++ b/internal/api/folders_search_test.go @@ -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) diff --git a/internal/entity/album.go b/internal/entity/album.go index 0fbf3e092..debb44450 100644 --- a/internal/entity/album.go +++ b/internal/entity/album.go @@ -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(), diff --git a/internal/entity/album_test.go b/internal/entity/album_test.go index bf2b66178..12cfea30f 100644 --- a/internal/entity/album_test.go +++ b/internal/entity/album_test.go @@ -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) diff --git a/internal/entity/entity_const.go b/internal/entity/entity_const.go index 806835065..ba340fd95 100644 --- a/internal/entity/entity_const.go +++ b/internal/entity/entity_const.go @@ -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 -) diff --git a/internal/entity/folder.go b/internal/entity/folder.go index 3627b22ac..ed59c1ad4 100644 --- a/internal/entity/folder.go +++ b/internal/entity/folder.go @@ -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, diff --git a/internal/entity/folder_test.go b/internal/entity/folder_test.go index 3acf995d4..06b024869 100644 --- a/internal/entity/folder_test.go +++ b/internal/entity/folder_test.go @@ -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) diff --git a/internal/query/albums.go b/internal/query/albums.go index d7484f69f..ee12a5478 100644 --- a/internal/query/albums.go +++ b/internal/query/albums.go @@ -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 diff --git a/internal/search/albums.go b/internal/search/albums.go index 3aa4bce18..ebf5a709d 100644 --- a/internal/search/albums.go +++ b/internal/search/albums.go @@ -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 { diff --git a/internal/search/photos.go b/internal/search/photos.go index e6a2520c2..8e87480bb 100644 --- a/internal/search/photos.go +++ b/internal/search/photos.go @@ -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 diff --git a/internal/search/photos_test.go b/internal/search/photos_test.go index c1f34799c..112ccf40f 100644 --- a/internal/search/photos_test.go +++ b/internal/search/photos_test.go @@ -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 { diff --git a/pkg/sortby/const.go b/pkg/sortby/const.go new file mode 100644 index 000000000..2bc8f7696 --- /dev/null +++ b/pkg/sortby/const.go @@ -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" +) diff --git a/pkg/sortby/random.go b/pkg/sortby/random.go new file mode 100644 index 000000000..0adbfa844 --- /dev/null +++ b/pkg/sortby/random.go @@ -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()") + } +} diff --git a/pkg/sortby/random_test.go b/pkg/sortby/random_test.go new file mode 100644 index 000000000..4f7f91f02 --- /dev/null +++ b/pkg/sortby/random_test.go @@ -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)) +} diff --git a/pkg/sortby/sortorder.go b/pkg/sortby/sortorder.go new file mode 100644 index 000000000..2d3baa258 --- /dev/null +++ b/pkg/sortby/sortorder.go @@ -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"): + + + 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: + + +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: + +*/ +package sortby