diff --git a/internal/api/albums.go b/internal/api/albums.go index d11a84d24d3..c29e3c906b9 100644 --- a/internal/api/albums.go +++ b/internal/api/albums.go @@ -459,6 +459,10 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) { event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), clean.Log(a.Title())) } + if err := a.ResetCoverIfNeeded(removed); err != nil { + log.Errorf("album: %s (reset thumbnail)", err) + } + RemoveFromAlbumCoverCache(a.AlbumUID) PublishAlbumEvent(EntityUpdated, a.AlbumUID, c) diff --git a/internal/api/batch.go b/internal/api/batch.go index e7e714e7869..c2fbef364ba 100644 --- a/internal/api/batch.go +++ b/internal/api/batch.go @@ -281,6 +281,17 @@ func BatchPhotosPrivate(router *gin.RouterGroup) { } event.EntitiesUpdated("photos", photos) + + // Reset the album covers, if any of the private photos were used as thumbnails. + if fileHashes, err := photos.Private().AllFileHashes(); err != nil { + log.Errorf("photos: %s (retrieve private photos file hashes)", err) + } else if len(fileHashes) > 0 { + if updated, err := query.RemovePhotosAsAlbumCovers(fileHashes); err != nil { + log.Errorf("photos: %s (removing private photos as album covers)", err) + } else { + log.Infof("photos: removed %d private %s as album covers", updated, english.PluralWord(int(updated), "photo", "photos")) + } + } } UpdateClientConfig() diff --git a/internal/entity/album.go b/internal/entity/album.go index 54ecbb10682..fc9b7cab9c4 100644 --- a/internal/entity/album.go +++ b/internal/entity/album.go @@ -287,7 +287,7 @@ func NewMonthAlbum(albumTitle, albumSlug string, year, month int) *Album { return result } -// FindLabelAlbums finds all label albums (including deletes ones) or returns nil. +// FindLabelAlbums finds all label-based moment albums (including deletes ones) or returns nil. func FindLabelAlbums() (result Albums) { // Both "label" and "country / year" album have the same album type, // so to distinguish between the two we have few options: @@ -306,6 +306,16 @@ func FindLabelAlbums() (result Albums) { return result } +// FindAlbumByType finds all smart albums (including deleted ones) or returns nil. +func FindSmartAlbums() (result Albums) { + if err := UnscopedDb().Where("album_type = ? AND album_filter != ''", AlbumManual).Find(&result).Error; err != nil { + log.Errorf("album: %s (not found)", err) + return nil + } + + return result +} + // FindCountriesByYearAlbums finds all moment albums (including deleted ones) or returns nil. func FindCountriesByYearAlbums() (result Albums) { if err := UnscopedDb(). @@ -895,3 +905,23 @@ func (m *Album) RemovePhotos(UIDs []string) (removed PhotoAlbums) { func (m *Album) Links() Links { return FindLinks("", m.AlbumUID) } + +// ResetCoverIfNeeded removes the album cover if it was part of the removed album photos. +func (m *Album) ResetCoverIfNeeded(removed PhotoAlbums) error { + if !m.HasThumb() { + return nil + } + + f, err := FirstFileByHash(m.Thumb) + if err != nil { + return err + } + + for _, pa := range removed { + if pa.PhotoUID == f.PhotoUID { + return m.ResetThumb() + } + } + + return nil +} diff --git a/internal/entity/photo.go b/internal/entity/photo.go index ec1c5cbb551..6d098542454 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -45,6 +45,28 @@ func (m Photos) UIDs() []string { return result } +// AllFileHashes returns the hashes for all photos files. +func (m Photos) AllFileHashes() (hashes []string, err error) { + err = Db().Model(File{}). + Where("photo_uid IN (?)", m.UIDs()). + Pluck("file_hash", &hashes).Error + + return hashes, err +} + +// Private returns a slice of all private photos. +func (m Photos) Private() Photos { + result := make(Photos, 0, len(m)) + + for _, el := range m { + if el.PhotoPrivate { + result = append(result, el) + } + } + + return result +} + // MapKey returns a key referencing time and location for indexing. func MapKey(takenAt time.Time, cellId string) string { return path.Join(strconv.FormatInt(takenAt.Unix(), 36), cellId) diff --git a/internal/query/covers.go b/internal/query/covers.go index b14ded6a866..82ec2d02444 100644 --- a/internal/query/covers.go +++ b/internal/query/covers.go @@ -9,8 +9,11 @@ import ( "github.com/jinzhu/gorm" "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/mutex" + "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/pkg/media" + "github.com/photoprism/photoprism/pkg/sortby" ) // UpdateAlbumDefaultCovers updates default album cover thumbs. @@ -37,7 +40,7 @@ func UpdateAlbumDefaultCovers() (err error) { case SQLite3: res = Db().Table(entity.Album{}.TableName()). UpdateColumn("thumb", gorm.Expr(`( - SELECT f.file_hash FROM files f + SELECT f.file_hash FROM files f JOIN photos_albums pa ON pa.album_uid = albums.album_uid AND pa.photo_uid = f.photo_uid AND pa.hidden = 0 AND pa.missing = 0 JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > 0 WHERE f.deleted_at IS NULL AND f.file_missing = 0 AND f.file_hash <> '' AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) @@ -88,7 +91,7 @@ func UpdateAlbumFolderCovers() (err error) { WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL GROUP BY p.photo_path ) b - WHERE f.photo_id = b.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) + WHERE f.photo_id = b.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) AND b.photo_path = albums.album_path LIMIT 1) WHERE ?`, media.PreviewExpr, condition)) default: @@ -109,6 +112,54 @@ func UpdateAlbumFolderCovers() (err error) { return err } +// UpdateAlbumMomentCovers updates moment album cover thumbs. +func UpdateAlbumMomentCovers() (err error) { + mutex.Index.Lock() + defer mutex.Index.Unlock() + + start := time.Now() + + var res *gorm.DB + + condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumMoment, entity.SrcAuto) + + switch DbDialect() { + case MySQL: + res = Db().Exec(`UPDATE albums LEFT JOIN ( + SELECT p2.photo_year, p2.photo_country, f.file_hash FROM files f, ( + SELECT p.photo_year, p.photo_country, max(p.id) AS photo_id FROM photos p + WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL + GROUP BY p.photo_year, p.photo_country) p2 WHERE p2.photo_id = f.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) + ) b ON b.photo_year = albums.album_year AND b.photo_country = albums.album_country + SET thumb = b.file_hash WHERE ?`, media.PreviewExpr, condition) + case SQLite3: + res = Db().Table(entity.Album{}.TableName()).UpdateColumn("thumb", gorm.Expr(`( + SELECT f.file_hash FROM files f,( + SELECT p.photo_year, p.photo_country, max(p.id) AS photo_id FROM photos p + WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL + GROUP BY p.photo_year, p.photo_country + ) b + WHERE f.photo_id = b.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) + AND b.photo_year = albums.album_year AND b.photo_country = albums.album_country LIMIT 1) + WHERE ?`, media.PreviewExpr, condition)) + default: + log.Warnf("sql: unsupported dialect %s", DbDialect()) + return nil + } + + err = res.Error + + if err == nil { + log.Debugf("covers: updated %s [%s]", english.Plural(int(res.RowsAffected), "moment", "moments"), time.Since(start)) + } else if strings.Contains(err.Error(), "Error 1054") { + log.Errorf("covers: failed updating moments, potentially incompatible database version") + log.Errorf("%s see https://jira.mariadb.org/browse/MDEV-25362", err) + return nil + } + + return err +} + // UpdateAlbumMonthCovers updates month album cover thumbs. func UpdateAlbumMonthCovers() (err error) { mutex.Index.Lock() @@ -157,6 +208,158 @@ func UpdateAlbumMonthCovers() (err error) { return err } +// UpdateAlbumStateCovers updates state album cover thumbs. +func UpdateAlbumStateCovers() (err error) { + mutex.Index.Lock() + defer mutex.Index.Unlock() + + start := time.Now() + + var res *gorm.DB + + condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumState, entity.SrcAuto) + + switch DbDialect() { + case MySQL: + res = Db().Exec(`UPDATE albums LEFT JOIN ( + SELECT p2.place_state, f.file_hash FROM files f, ( + SELECT pl.place_state, max(p.id) AS photo_id FROM photos p + JOIN places pl ON pl.id = p.place_id + WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL + GROUP BY pl.place_state) p2 WHERE p2.photo_id = f.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) + ) b ON b.place_state = albums.album_state + SET thumb = b.file_hash WHERE ?`, media.PreviewExpr, condition) + case SQLite3: + res = Db().Table(entity.Album{}.TableName()).UpdateColumn("thumb", gorm.Expr(`( + SELECT f.file_hash FROM files f,( + SELECT pl.place_state, max(p.id) AS photo_id FROM photos p + JOIN places pl ON pl.id = p.place_id + WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL + GROUP BY pl.place_state + ) b + WHERE f.photo_id = b.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) + AND b.place_state = albums.album_state LIMIT 1) + WHERE ?`, media.PreviewExpr, condition)) + default: + log.Warnf("sql: unsupported dialect %s", DbDialect()) + return nil + } + + err = res.Error + + if err == nil { + log.Debugf("covers: updated %s [%s]", english.Plural(int(res.RowsAffected), "state", "states"), time.Since(start)) + } else if strings.Contains(err.Error(), "Error 1054") { + log.Errorf("covers: failed updating states, potentially incompatible database version") + log.Errorf("%s see https://jira.mariadb.org/browse/MDEV-25362", err) + return nil + } + + return err +} + +// UpdateAlbumCountryCovers updates country album cover thumbs. +func UpdateAlbumCountryCovers() (err error) { + mutex.Index.Lock() + defer mutex.Index.Unlock() + + start := time.Now() + + var res *gorm.DB + + condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumCountry, entity.SrcAuto) + + switch DbDialect() { + case MySQL: + res = Db().Exec(`UPDATE albums LEFT JOIN ( + SELECT p2.photo_country, f.file_hash FROM files f, ( + SELECT p.photo_country, max(p.id) AS photo_id FROM photos p + WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL + GROUP BY p.photo_country) p2 WHERE p2.photo_id = f.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) + ) b ON b.photo_country = albums.album_country + SET thumb = b.file_hash WHERE ?`, media.PreviewExpr, condition) + case SQLite3: + res = Db().Table(entity.Album{}.TableName()).UpdateColumn("thumb", gorm.Expr(`( + SELECT f.file_hash FROM files f,( + SELECT p.photo_country, max(p.id) AS photo_id FROM photos p + WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL + GROUP BY p.photo_country + ) b + WHERE f.photo_id = b.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) + AND b.photo_country = albums.album_country LIMIT 1) + WHERE ?`, media.PreviewExpr, condition)) + default: + log.Warnf("sql: unsupported dialect %s", DbDialect()) + return nil + } + + err = res.Error + + if err == nil { + log.Debugf("covers: updated %s [%s]", english.Plural(int(res.RowsAffected), "country", "countries"), time.Since(start)) + } else if strings.Contains(err.Error(), "Error 1054") { + log.Errorf("covers: failed updating countries, potentially incompatible database version") + log.Errorf("%s see https://jira.mariadb.org/browse/MDEV-25362", err) + return nil + } + + return err +} + + +func UpdateSmartAlbumCovers() (err error) { + start := time.Now() + + updated, err := updateDynamicAlbumCovers(entity.FindSmartAlbums()) + log.Debugf("covers: updated %s [%s]", english.Plural(updated, "smart album", "smart albums"), time.Since(start)) + + return err +} + +func UpdateMomentLabelAlbumCovers() (err error) { + start := time.Now() + + updated, err := updateDynamicAlbumCovers(entity.FindLabelAlbums()) + log.Debugf("covers: updated %s [%s]", english.Plural(updated, "label moment", "label moments"), time.Since(start)) + + return err +} + +// updateDynamicAlbumCovers updates the thumbnail for dynamic albums without a manual cover to the last photo. +func updateDynamicAlbumCovers(albums entity.Albums) (updated int, err error) { + for _, a := range albums { + if a.ThumbSrc != entity.SrcAuto { + continue + } + + f := form.SearchPhotos{Album: a.AlbumUID, Filter: a.AlbumFilter, Public: true, Order: sortby.Newest, Count: 1, Offset: 0, Merged: false} + + if err = f.ParseQueryString(); err != nil { + return updated, err + } + + if photos, _, err := search.Photos(f); err != nil { + return updated, err + } else if len(photos) > 0 { + photo := photos[0] + file, err := FileByUID(photo.FileUID) + if err != nil { + return updated, err + } + + a.Thumb = file.FileHash + a.ThumbSrc = entity.SrcAuto + if err := a.Save(); err != nil { + return updated, err + } + + updated++ + } + } + + return updated, nil +} + // UpdateAlbumCovers updates album cover thumbs. func UpdateAlbumCovers() (err error) { // Update Default Albums. @@ -169,11 +372,38 @@ func UpdateAlbumCovers() (err error) { return err } + // Update Moment Albums. + if err = UpdateAlbumMomentCovers(); err != nil { + return err + } + // Update Monthly Albums. if err = UpdateAlbumMonthCovers(); err != nil { return err } + // Update State Albums. + if err = UpdateAlbumStateCovers(); err != nil { + return err + } + + // Update Country Albums. + if err = UpdateAlbumCountryCovers(); err != nil { + return err + } + + // TODO: The albums with dynamic filters require N+1 queries to calculate the thumbnail. Optimize. + + // Update label-based moment albums. + if err = UpdateMomentLabelAlbumCovers(); err != nil { + return err + } + + // Update smart albums. + if err = UpdateSmartAlbumCovers(); err != nil { + return err + } + return nil } @@ -207,7 +437,7 @@ func UpdateLabelCovers() (err error) { SET thumb = b.file_hash WHERE ?`, media.PreviewExpr, condition) case SQLite3: res = Db().Table(entity.Label{}.TableName()).UpdateColumn("thumb", gorm.Expr(`( - SELECT f.file_hash FROM files f + SELECT f.file_hash FROM files f JOIN photos_labels pl ON pl.label_id = labels.id AND pl.photo_id = f.photo_id AND pl.uncertainty < 100 JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > 0 WHERE f.deleted_at IS NULL AND f.file_hash <> '' AND f.file_missing = 0 AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) @@ -216,7 +446,7 @@ func UpdateLabelCovers() (err error) { if res.Error == nil { catRes := Db().Table(entity.Label{}.TableName()).UpdateColumn("thumb", gorm.Expr(`( - SELECT f.file_hash FROM files f + SELECT f.file_hash FROM files f JOIN photos_labels pl ON pl.photo_id = f.photo_id AND pl.uncertainty < 100 JOIN categories c ON c.label_id = pl.label_id AND c.category_id = labels.id JOIN photos p ON p.id = f.photo_id AND p.photo_private = 0 AND p.deleted_at IS NULL AND p.photo_quality > 0 @@ -315,3 +545,13 @@ func UpdateCovers() (err error) { return nil } + +// RemovePhotosAsAlbumCovers resets the thumbnails for any albums that use one of the given file hashes as covers. +func RemovePhotosAsAlbumCovers(photoFilesHashes []string) (updated int64, err error) { + res := Db(). + Model(entity.Album{}). + Where("thumb IN (?)", photoFilesHashes). + UpdateColumns(entity.Values{"thumb": "", "thumb_src": entity.SrcAuto}) + + return res.RowsAffected, res.Error +}