Albums: Improve performance when setting/refreshing cover images #5253

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-10 17:51:55 +02:00
parent 92d21af697
commit c5d17c579c
4 changed files with 362 additions and 25 deletions

View File

@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config/ttl" "github.com/photoprism/photoprism/internal/config/ttl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query" "github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/thumb"
@@ -69,8 +70,19 @@ func RemoveFromAlbumCoverCache(uid string) {
_ = os.Remove(sharePreview) _ = os.Remove(sharePreview)
} }
// Update album cover images. album, err := query.AlbumByUID(uid)
if err := query.UpdateAlbumCovers(); err != nil {
if err != nil {
log.Error(err)
return
}
// Manual covers stay untouched; we only regenerate auto-managed entries.
if album.ThumbSrc != entity.SrcAuto {
return
}
if err = query.UpdateAlbumCovers(album); err != nil {
log.Error(err) log.Error(err)
} }
} }

View File

@@ -9,8 +9,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
@@ -56,7 +58,12 @@ func TestRemoveFromAlbumCoverCache(t *testing.T) {
cache := get.CoverCache() cache := get.CoverCache()
cache.Flush() cache.Flush()
uid := rnd.GenerateUID(entity.AlbumUID) var album entity.Album
if err := query.UnscopedDb().Where("album_type = ? AND thumb_src = ?", entity.AlbumManual, entity.SrcAuto).First(&album).Error; err != nil {
t.Skipf("no auto-managed manual album available: %v", err)
}
uid := album.AlbumUID
for thumbName := range thumb.Sizes { for thumbName := range thumb.Sizes {
key := CacheKey(albumCover, uid, string(thumbName)) key := CacheKey(albumCover, uid, string(thumbName))
@@ -76,6 +83,15 @@ func TestRemoveFromAlbumCoverCache(t *testing.T) {
t.Fatalf("write %s: %v", sharePreview, err) t.Fatalf("write %s: %v", sharePreview, err)
} }
origThumb := album.Thumb
origThumbSrc := album.ThumbSrc
t.Cleanup(func() {
_ = entity.UpdateAlbum(uid, entity.Values{"thumb": origThumb, "thumb_src": origThumbSrc})
})
require.NoError(t, entity.UpdateAlbum(uid, entity.Values{"thumb": "", "thumb_src": entity.SrcAuto}))
RemoveFromAlbumCoverCache(uid) RemoveFromAlbumCoverCache(uid)
for thumbName := range thumb.Sizes { for thumbName := range thumb.Sizes {
@@ -86,6 +102,12 @@ func TestRemoveFromAlbumCoverCache(t *testing.T) {
_, err := os.Stat(sharePreview) _, err := os.Stat(sharePreview)
assert.True(t, os.IsNotExist(err)) assert.True(t, os.IsNotExist(err))
entity.FlushAlbumCache()
refreshed, err := query.AlbumByUID(uid)
require.NoError(t, err)
assert.NotEmpty(t, refreshed.Thumb)
} }
func TestRemoveFromAlbumCoverCacheInvalidUID(t *testing.T) { func TestRemoveFromAlbumCoverCacheInvalidUID(t *testing.T) {

View File

@@ -18,8 +18,9 @@ import (
// coversBusy is true when the covers are currently updating. // coversBusy is true when the covers are currently updating.
var coversBusy = atomic.Bool{} var coversBusy = atomic.Bool{}
// UpdateAlbumDefaultCovers updates default album cover thumbs. // UpdateAlbumManualCovers updates manual album cover thumbs. When albums are
func UpdateAlbumDefaultCovers() (err error) { // provided, the update is limited to auto-managed entries from that list.
func UpdateAlbumManualCovers(albums ...entity.Album) (err error) {
mutex.Index.Lock() mutex.Index.Lock()
defer mutex.Index.Unlock() defer mutex.Index.Unlock()
@@ -27,16 +28,30 @@ func UpdateAlbumDefaultCovers() (err error) {
var res *gorm.DB var res *gorm.DB
if len(albums) > 0 {
for _, album := range albums {
if album.AlbumType != entity.AlbumManual || album.ThumbSrc != entity.SrcAuto || album.AlbumUID == "" {
continue
}
if err = refreshAlbumCover(album); err != nil {
return err
}
}
return nil
}
condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumManual, entity.SrcAuto) condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumManual, entity.SrcAuto)
switch DbDialect() { switch DbDialect() {
case MySQL: case MySQL:
res = Db().Exec(`UPDATE albums LEFT JOIN ( res = Db().Exec(`UPDATE albums LEFT JOIN (
SELECT p2.album_uid, f.file_hash FROM files f, ( SELECT p2.album_uid, f.file_hash FROM files f, (
SELECT pa.album_uid, max(p.id) AS photo_id FROM photos p SELECT pa.album_uid, max(p.id) AS photo_id FROM photos p
JOIN photos_albums pa ON pa.photo_uid = p.photo_uid AND pa.hidden = 0 AND pa.missing = 0 JOIN photos_albums pa ON pa.photo_uid = p.photo_uid AND pa.hidden = 0 AND pa.missing = 0
WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL WHERE p.photo_quality > 0 AND p.photo_private = 0 AND p.deleted_at IS NULL
GROUP BY pa.album_uid) p2 WHERE p2.photo_id = f.photo_id AND f.file_primary = 1 AND f.file_error = '' AND f.file_type IN (?) GROUP BY pa.album_uid) 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.album_uid = albums.album_uid ) b ON b.album_uid = albums.album_uid
SET thumb = b.file_hash WHERE ?`, media.PreviewExpr, condition) SET thumb = b.file_hash WHERE ?`, media.PreviewExpr, condition)
case SQLite3: case SQLite3:
@@ -66,8 +81,9 @@ func UpdateAlbumDefaultCovers() (err error) {
return err return err
} }
// UpdateAlbumFolderCovers updates folder album cover thumbs. // UpdateAlbumFolderCovers updates folder album cover thumbs. When albums are
func UpdateAlbumFolderCovers() (err error) { // provided, the update is limited to auto-managed folders from that list.
func UpdateAlbumFolderCovers(albums ...entity.Album) (err error) {
mutex.Index.Lock() mutex.Index.Lock()
defer mutex.Index.Unlock() defer mutex.Index.Unlock()
@@ -75,6 +91,20 @@ func UpdateAlbumFolderCovers() (err error) {
var res *gorm.DB var res *gorm.DB
if len(albums) > 0 {
for _, album := range albums {
if album.AlbumType != entity.AlbumFolder || album.ThumbSrc != entity.SrcAuto || album.AlbumUID == "" {
continue
}
if err = refreshAlbumCover(album); err != nil {
return err
}
}
return nil
}
condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumFolder, entity.SrcAuto) condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumFolder, entity.SrcAuto)
switch DbDialect() { switch DbDialect() {
@@ -114,8 +144,9 @@ func UpdateAlbumFolderCovers() (err error) {
return err return err
} }
// UpdateAlbumMonthCovers updates month album cover thumbs. // UpdateAlbumMonthCovers updates month album cover thumbs. When albums are
func UpdateAlbumMonthCovers() (err error) { // provided, the update is limited to auto-managed months from that list.
func UpdateAlbumMonthCovers(albums ...entity.Album) (err error) {
mutex.Index.Lock() mutex.Index.Lock()
defer mutex.Index.Unlock() defer mutex.Index.Unlock()
@@ -123,6 +154,20 @@ func UpdateAlbumMonthCovers() (err error) {
var res *gorm.DB var res *gorm.DB
if len(albums) > 0 {
for _, album := range albums {
if album.AlbumType != entity.AlbumMonth || album.ThumbSrc != entity.SrcAuto || album.AlbumUID == "" {
continue
}
if err = refreshAlbumCover(album); err != nil {
return err
}
}
return nil
}
condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumMonth, entity.SrcAuto) condition := gorm.Expr("album_type = ? AND thumb_src = ?", entity.AlbumMonth, entity.SrcAuto)
switch DbDialect() { switch DbDialect() {
@@ -162,21 +207,198 @@ func UpdateAlbumMonthCovers() (err error) {
return err return err
} }
// UpdateAlbumCovers updates album cover thumbs. // UpdateAlbumCovers updates album cover thumbs. When albums are provided, only
func UpdateAlbumCovers() (err error) { // those auto-managed entries are refreshed.
// Update Default Albums. func UpdateAlbumCovers(albums ...entity.Album) (err error) {
if err = UpdateAlbumDefaultCovers(); err != nil { if len(albums) == 0 {
if err = UpdateAlbumManualCovers(); err != nil {
return err
}
if err = UpdateAlbumFolderCovers(); err != nil {
return err
}
if err = UpdateAlbumMonthCovers(); err != nil {
return err
}
return nil
}
var manualAlbums, folderAlbums, monthAlbums []entity.Album
for _, album := range albums {
if album.ThumbSrc != entity.SrcAuto {
continue
}
switch album.AlbumType {
case entity.AlbumManual:
manualAlbums = append(manualAlbums, album)
case entity.AlbumFolder:
folderAlbums = append(folderAlbums, album)
case entity.AlbumMonth:
monthAlbums = append(monthAlbums, album)
}
}
if len(manualAlbums) > 0 {
if err = UpdateAlbumManualCovers(manualAlbums...); err != nil {
return err
}
}
if len(folderAlbums) > 0 {
if err = UpdateAlbumFolderCovers(folderAlbums...); err != nil {
return err
}
}
if len(monthAlbums) > 0 {
if err = UpdateAlbumMonthCovers(monthAlbums...); err != nil {
return err
}
}
return nil
}
// refreshAlbumCover recomputes the cover thumb for a single album when the
// cover is managed automatically.
func refreshAlbumCover(album entity.Album) error {
if album.AlbumUID == "" {
return nil
}
var err error
switch album.AlbumType {
case entity.AlbumManual:
err = refreshManualAlbumCover(album)
case entity.AlbumFolder:
err = refreshFolderAlbumCover(album)
case entity.AlbumMonth:
err = refreshMonthAlbumCover(album)
default:
return nil
}
if err != nil {
if strings.Contains(err.Error(), "no rows") || strings.Contains(err.Error(), "no cover") {
return nil
}
return err return err
} }
// Update Folder Albums. entity.FlushAlbumCache()
if err = UpdateAlbumFolderCovers(); err != nil {
return nil
}
// refreshManualAlbumCover updates the cover for a single manual album.
func refreshManualAlbumCover(album entity.Album) error {
file, err := AlbumCoverByUID(album.AlbumUID, false)
if err != nil {
if strings.Contains(err.Error(), "no cover") {
return nil
}
return err return err
} }
// Update Monthly Albums. if file.FileHash == "" {
if err = UpdateAlbumMonthCovers(); err != nil { return nil
return err }
return entity.UpdateAlbum(album.AlbumUID, entity.Values{"thumb": file.FileHash})
}
// refreshFolderAlbumCover updates the cover for a single folder album.
func refreshFolderAlbumCover(album entity.Album) error {
if album.AlbumPath == "" {
return nil
}
switch DbDialect() {
case MySQL:
res := Db().Exec(`UPDATE albums LEFT JOIN (
SELECT p2.photo_path, f.file_hash FROM files f, (
SELECT p.photo_path, 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 AND p.photo_path = ?
GROUP BY p.photo_path) 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_path = albums.album_path
SET thumb = b.file_hash WHERE albums.album_uid = ? AND albums.album_type = ? AND albums.thumb_src = ?`,
album.AlbumPath,
media.PreviewExpr,
album.AlbumUID,
entity.AlbumFolder,
entity.SrcAuto,
)
return res.Error
case SQLite3:
res := Db().Table(entity.Album{}.TableName()).
Where("album_uid = ? AND album_type = ? AND thumb_src = ?", album.AlbumUID, entity.AlbumFolder, entity.SrcAuto).
UpdateColumn("thumb", gorm.Expr(`(
SELECT f.file_hash FROM files f,(
SELECT p.photo_path, 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 AND p.photo_path = ?
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 (?)
AND b.photo_path = albums.album_path LIMIT 1
)`, album.AlbumPath, media.PreviewExpr))
return res.Error
default:
log.Warnf("sql: unsupported dialect %s", DbDialect())
}
return nil
}
// refreshMonthAlbumCover updates the cover for a single month album.
func refreshMonthAlbumCover(album entity.Album) error {
if album.AlbumYear == 0 && album.AlbumMonth == 0 {
return nil
}
switch DbDialect() {
case MySQL:
res := Db().Exec(`UPDATE albums LEFT JOIN (
SELECT p2.photo_year, p2.photo_month, f.file_hash FROM files f, (
SELECT p.photo_year, p.photo_month, 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 AND p.photo_year = ? AND p.photo_month = ?
GROUP BY p.photo_year, p.photo_month) 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_month = albums.album_month
SET thumb = b.file_hash WHERE albums.album_uid = ? AND albums.album_type = ? AND albums.thumb_src = ?`,
album.AlbumYear,
album.AlbumMonth,
media.PreviewExpr,
album.AlbumUID,
entity.AlbumMonth,
entity.SrcAuto,
)
return res.Error
case SQLite3:
res := Db().Table(entity.Album{}.TableName()).
Where("album_uid = ? AND album_type = ? AND thumb_src = ?", album.AlbumUID, entity.AlbumMonth, entity.SrcAuto).
UpdateColumn("thumb", gorm.Expr(`(
SELECT f.file_hash FROM files f,(
SELECT p.photo_year, p.photo_month, 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 AND p.photo_year = ? AND p.photo_month = ?
GROUP BY p.photo_year, p.photo_month
) 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_month = albums.album_month LIMIT 1
)`, album.AlbumYear, album.AlbumMonth, media.PreviewExpr))
return res.Error
default:
log.Warnf("sql: unsupported dialect %s", DbDialect())
} }
return nil return nil

View File

@@ -4,20 +4,101 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/entity"
) )
func TestUpdateAlbumDefaultCovers(t *testing.T) { func TestUpdateAlbumManualCovers(t *testing.T) {
assert.NoError(t, UpdateAlbumDefaultCovers()) assert.NoError(t, UpdateAlbumManualCovers())
}
func TestUpdateAlbumManualCoversFiltered(t *testing.T) {
var album entity.Album
if err := UnscopedDb().Where("album_type = ? AND thumb_src = ? AND thumb <> ''", entity.AlbumManual, entity.SrcAuto).First(&album).Error; err != nil {
t.Skipf("no auto-managed manual album available: %v", err)
}
origThumb := album.Thumb
origSrc := album.ThumbSrc
t.Cleanup(func() {
_ = entity.UpdateAlbum(album.AlbumUID, entity.Values{"thumb": origThumb, "thumb_src": origSrc})
entity.FlushAlbumCache()
})
require.NoError(t, entity.UpdateAlbum(album.AlbumUID, entity.Values{"thumb": "", "thumb_src": entity.SrcAuto}))
entity.FlushAlbumCache()
require.NoError(t, UpdateAlbumManualCovers(album))
entity.FlushAlbumCache()
refreshed, err := AlbumByUID(album.AlbumUID)
require.NoError(t, err)
assert.NotEmpty(t, refreshed.Thumb)
} }
func TestUpdateAlbumFolderCovers(t *testing.T) { func TestUpdateAlbumFolderCovers(t *testing.T) {
assert.NoError(t, UpdateAlbumFolderCovers()) assert.NoError(t, UpdateAlbumFolderCovers())
} }
func TestUpdateAlbumFolderCoversFiltered(t *testing.T) {
var album entity.Album
if err := UnscopedDb().Where("album_type = ? AND thumb_src = ? AND album_path <> '' AND thumb <> ''", entity.AlbumFolder, entity.SrcAuto).First(&album).Error; err != nil {
t.Skipf("no auto-managed folder album available: %v", err)
}
origThumb := album.Thumb
origSrc := album.ThumbSrc
t.Cleanup(func() {
_ = entity.UpdateAlbum(album.AlbumUID, entity.Values{"thumb": origThumb, "thumb_src": origSrc})
entity.FlushAlbumCache()
})
require.NoError(t, entity.UpdateAlbum(album.AlbumUID, entity.Values{"thumb": "", "thumb_src": entity.SrcAuto}))
entity.FlushAlbumCache()
require.NoError(t, UpdateAlbumFolderCovers(album))
entity.FlushAlbumCache()
refreshed, err := AlbumByUID(album.AlbumUID)
require.NoError(t, err)
assert.NotEmpty(t, refreshed.Thumb)
}
func TestUpdateAlbumMonthCovers(t *testing.T) { func TestUpdateAlbumMonthCovers(t *testing.T) {
assert.NoError(t, UpdateAlbumMonthCovers()) assert.NoError(t, UpdateAlbumMonthCovers())
} }
func TestUpdateAlbumMonthCoversFiltered(t *testing.T) {
var album entity.Album
if err := UnscopedDb().Where("album_type = ? AND thumb_src = ? AND album_year <> 0 AND thumb <> ''", entity.AlbumMonth, entity.SrcAuto).First(&album).Error; err != nil {
t.Skipf("no auto-managed monthly album available: %v", err)
}
origThumb := album.Thumb
origSrc := album.ThumbSrc
t.Cleanup(func() {
_ = entity.UpdateAlbum(album.AlbumUID, entity.Values{"thumb": origThumb, "thumb_src": origSrc})
entity.FlushAlbumCache()
})
require.NoError(t, entity.UpdateAlbum(album.AlbumUID, entity.Values{"thumb": "", "thumb_src": entity.SrcAuto}))
entity.FlushAlbumCache()
require.NoError(t, UpdateAlbumMonthCovers(album))
entity.FlushAlbumCache()
refreshed, err := AlbumByUID(album.AlbumUID)
require.NoError(t, err)
assert.NotEmpty(t, refreshed.Thumb)
}
func TestUpdateAlbumCovers(t *testing.T) { func TestUpdateAlbumCovers(t *testing.T) {
assert.NoError(t, UpdateAlbumCovers()) assert.NoError(t, UpdateAlbumCovers())
} }