Batch Edit: Refactor "batch" package and related API endpoint #271 #5324

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2025-11-17 16:50:20 +01:00
parent de0500369f
commit a959ea5eae
17 changed files with 1555 additions and 174 deletions

View file

@ -644,7 +644,9 @@ export default {
},
computed: {
formTitle() {
return this.$gettext(`Edit Photos (%{n})`, { n: this.allSelectedLength });
// TODO: this.allSelectedLength should not include photos that could not be loaded e.g. because they are archived.
const n = Number(this.allSelectedLength) > this.getModelCount() ? this.getModelCount() : 0;
return this.$gettext(`Edit Photos (%{n})`, { n });
},
currentCoordinates() {
if (this.isLocationMixed || this.deletedFields.Lat || this.deletedFields.Lng) {
@ -799,6 +801,9 @@ export default {
this.allSelectedLength = 0;
this.$view.leave(this);
},
getModelCount() {
return this.model?.models?.length ? this.model.models.length : 0;
},
normalizeLabelTitleForCompare(s) {
return $util.normalizeLabelTitle(s);
},

View file

@ -1,12 +1,10 @@
package api
import (
"fmt"
"net/http"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/ulule/deepcopier"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity"
@ -30,12 +28,19 @@ import (
// @Router /api/v1/batch/photos/edit [post]
func BatchPhotosEdit(router *gin.RouterGroup) {
router.Match(MethodsPutPost, "/batch/photos/edit", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
// Require access to all photos.
s := Auth(c, acl.ResourcePhotos, acl.AccessAll)
if s.Abort(c) {
return
}
// Require update permissions for photos.
if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionUpdate) {
AbortForbidden(c)
return
}
var frm batch.PhotosRequest
// Assign and validate request form values.
@ -56,82 +61,83 @@ func BatchPhotosEdit(router *gin.RouterGroup) {
// Abort if no photos were found.
if err != nil {
log.Errorf("batch: %s", clean.Error(err))
log.Errorf("batch: %s (load selection)", clean.Error(err))
AbortUnexpectedError(c)
return
}
// Update photo metadata based on submitted form values.
if frm.Values != nil {
log.Debugf("batch: updating photo metadata for %d photos", len(photos))
updatedCount := 0
preloadedPhotos := map[string]*entity.Photo{}
for i, photo := range photos {
photoID := photo.PhotoUID
// Get the full photo entity with preloaded data
fullPhoto, err := query.PhotoPreloadByUID(photoID)
if err != nil {
log.Errorf("batch: failed to load photo %s: %s", photoID, err)
continue
}
// Convert batch form to regular photo form
photoForm, err := batch.ConvertToPhotoForm(&fullPhoto, frm.Values)
if err != nil {
log.Errorf("batch: failed to convert form for photo %s: %s", photoID, err)
continue
}
// Use the same save mechanism as normal edit
if err := entity.SavePhotoForm(&fullPhoto, *photoForm); err != nil {
log.Errorf("batch: failed to save photo %s: %s", photoID, err)
continue
}
// Apply Albums updates if requested
if frm.Values.Albums.Action == batch.ActionUpdate {
if err := batch.ApplyAlbums(photoID, frm.Values.Albums); err != nil {
log.Errorf("batch: failed to update albums for photo %s: %s", photoID, err)
}
}
// Apply Labels updates if requested
if frm.Values.Labels.Action == batch.ActionUpdate {
if err := batch.ApplyLabels(&fullPhoto, frm.Values.Labels); err != nil {
log.Errorf("batch: failed to update labels for photo %s: %s", photoID, err)
}
}
// Convert the updated entity.Photo back to search.Photo and update the results array
updatedSearchPhoto, convertErr := convertEntityToSearchPhoto(&fullPhoto)
if convertErr != nil {
log.Errorf("batch: failed to convert photo %s to search result: %s", photoID, convertErr)
} else {
photos[i] = *updatedSearchPhoto
}
updatedCount++
// Save sidecar YAML if enabled
SaveSidecarYaml(&fullPhoto)
log.Debugf("batch: successfully updated photo %s", photoID)
}
log.Infof("batch: successfully updated %d out of %d photos", updatedCount, len(photos))
// Publish photo update events
for _, photo := range photos {
PublishPhotoEvent(StatusUpdated, photo.PhotoUID, c)
}
// Update client config and flush cache
UpdateClientConfig()
FlushCoverCache()
if hydrated, err := query.PhotoPreloadByUIDs(photos.UIDs()); err != nil {
log.Errorf("batch: failed to preload photo selection: %s", err)
AbortUnexpectedError(c)
return
} else {
preloadedPhotos = mapPhotosByUID(hydrated)
}
// Create batch edit form values form from photo metadata.
batchFrm := batch.NewPhotosForm(photos)
var (
saveRequests []*batch.PhotoSaveRequest
saveResults []bool
savedAny bool
)
if frm.Values != nil {
outcome, saveErr := batch.PrepareAndSavePhotos(photos, preloadedPhotos, frm.Values)
if saveErr != nil {
log.Errorf("batch: failed to persist photo updates: %s", saveErr)
AbortUnexpectedError(c)
return
}
saveRequests = outcome.Requests
saveResults = outcome.Results
preloadedPhotos = outcome.Preloaded
savedAny = outcome.SavedAny
}
// Refresh the selection once so Albums / Labels reflect the committed state when we
// build the form that is returned to the client.
refreshedPhotos := preloadedPhotos
if hydrated, err := query.PhotoPreloadByUIDs(photos.UIDs()); err != nil {
log.Warnf("batch: failed to refresh photos for response: %s", err)
} else {
refreshedPhotos = mapPhotosByUID(hydrated)
}
// Refresh selected photos from database?
if !savedAny {
// Don't refresh.
} else if photos, count, err = search.BatchPhotos(frm.Photos, s); err != nil {
log.Errorf("batch: %s (refresh selection)", clean.Error(err))
}
// Create batch edit form values form from photo metadata using the refreshed entities so
// the response reflects persisted album/label edits without issuing per-photo queries.
batchFrm := batch.NewPhotosFormWithEntities(photos, refreshedPhotos)
if len(saveResults) > 0 {
for i, saved := range saveResults {
if !saved {
continue
}
photo := refreshedPhotos[saveRequests[i].Photo.PhotoUID]
if photo == nil {
photo = saveRequests[i].Photo
}
// PublishPhotoEvent(StatusUpdated, photo.PhotoUID, c)
SaveSidecarYaml(photo)
}
if savedAny {
UpdateClientConfig()
FlushCoverCache()
}
}
// Return models and form values.
data := batch.PhotosResponse{
@ -143,23 +149,15 @@ func BatchPhotosEdit(router *gin.RouterGroup) {
})
}
// convertEntityToSearchPhoto converts an entity.Photo to search.Photo for API responses.
func convertEntityToSearchPhoto(photo *entity.Photo) (*search.Photo, error) {
searchPhoto := &search.Photo{}
// Copy common fields automatically
deepcopier.Copy(searchPhoto).From(photo)
// Set required fields manually
searchPhoto.CompositeID = fmt.Sprintf("%d", photo.ID)
// Copy details if they exist
if details := photo.GetDetails(); details != nil {
searchPhoto.DetailsSubject = details.Subject
searchPhoto.DetailsArtist = details.Artist
searchPhoto.DetailsCopyright = details.Copyright
searchPhoto.DetailsLicense = details.License
// mapPhotosByUID converts the provided list into a UID keyed lookup map so repeated
// selections can reuse already preloaded entities instead of querying again.
func mapPhotosByUID(photos entity.Photos) map[string]*entity.Photo {
result := make(map[string]*entity.Photo, len(photos))
for _, e := range photos {
if e == nil || e.PhotoUID == "" {
continue
}
result[e.PhotoUID] = e
}
return searchPhoto, nil
return result
}

View file

@ -37,7 +37,10 @@ var (
DefaultOrderMonth = sortby.Oldest
)
var albumMutex = sync.Mutex{}
var (
albumGlobalLock = sync.Mutex{}
albumLocks sync.Map // map[string]*sync.Mutex keyed by UID or normalized title
)
// Albums is a helper slice type for working with groups of albums.
type Albums []Album
@ -120,40 +123,78 @@ func AddPhotoToUserAlbums(photoUid string, albums []string, sortOrder, userUid s
return fmt.Errorf("album: can not add invalid photo uid %s", clean.Log(photoUid))
}
albumMutex.Lock()
defer albumMutex.Unlock()
for _, album := range albums {
var albumUid string
if album == "" {
log.Debugf("album: cannot add photo uid %s because album id was not specified", clean.Log(photoUid))
continue
}
if rnd.IsUID(album, AlbumUID) {
albumUid = album
} else {
a := NewUserAlbum(album, AlbumManual, sortOrder, userUid)
if found := a.Find(); found != nil {
albumUid = found.AlbumUID
} else if err = a.Create(); err == nil {
albumUid = a.AlbumUID
} else {
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
}
unlock := lockAlbumKey(album)
if lockErr := addPhotoToAlbumLocked(photoUid, album, sortOrder, userUid); lockErr != nil {
err = lockErr
}
unlock()
}
if albumUid != "" {
entry := PhotoAlbum{AlbumUID: albumUid, PhotoUID: photoUid, Hidden: false}
return err
}
if err = entry.Save(); err != nil {
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
}
// lockAlbumKey acquires a per-album mutex keyed by UID or normalized title to avoid
// serializing unrelated album updates while still preventing duplicate creation when
// multiple goroutines target the same album concurrently.
func lockAlbumKey(album string) func() {
key := strings.TrimSpace(album)
// Refresh updated timestamp.
err = UpdateAlbum(albumUid, Values{"updated_at": TimeStamp()})
if key == "" {
albumGlobalLock.Lock()
return albumGlobalLock.Unlock
}
if rnd.IsUID(key, AlbumUID) {
// keep UID as-is so existing albums share the same lock
} else {
key = strings.ToLower(key)
}
locker, _ := albumLocks.LoadOrStore(key, &sync.Mutex{})
mu := locker.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// addPhotoToAlbumLocked performs the actual album lookup/creation and relation insert
// while assuming the caller already holds the per-album mutex.
func addPhotoToAlbumLocked(photoUid, album, sortOrder, userUid string) (err error) {
var albumUid string
if rnd.IsUID(album, AlbumUID) {
albumUid = album
} else {
a := NewUserAlbum(album, AlbumManual, sortOrder, userUid)
if found := a.Find(); found != nil {
albumUid = found.AlbumUID
} else if err = a.Create(); err == nil {
albumUid = a.AlbumUID
} else {
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
}
}
if albumUid == "" {
return err
}
entry := PhotoAlbum{AlbumUID: albumUid, PhotoUID: photoUid, Hidden: false}
if err = entry.Save(); err != nil {
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
}
if updateErr := UpdateAlbum(albumUid, Values{"updated_at": TimeStamp()}); updateErr != nil {
if err == nil {
err = updateErr
}
}

View file

@ -2,6 +2,7 @@ package entity
import (
"strings"
"sync"
"testing"
"time"
@ -12,6 +13,7 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// TestUpdateAlbum exercises the related album behavior.
func TestUpdateAlbum(t *testing.T) {
t.Run("InvalidUID", func(t *testing.T) {
err := UpdateAlbum("xxx", Values{"album_title": "New Title", "album_slug": "new-slug"})
@ -20,6 +22,7 @@ func TestUpdateAlbum(t *testing.T) {
})
}
// TestAddPhotoToAlbums exercises the related album behavior.
func TestAddPhotoToAlbums(t *testing.T) {
t.Run("SuccessOneAlbum", func(t *testing.T) {
err := AddPhotoToAlbums("ps6sg6bexxvl0yh0", []string{"as6sg6bitoga0004"})
@ -113,6 +116,7 @@ func TestAddPhotoToAlbums(t *testing.T) {
})
}
// TestAddPhotoToUserAlbums exercises the related album behavior.
func TestAddPhotoToUserAlbums(t *testing.T) {
t.Run("AddToExistingAlbum", func(t *testing.T) {
err := AddPhotoToUserAlbums("ps6sg6bexxvl0yh0", []string{"as6sg6bitoga0004"}, sortby.Oldest, "uqxetse3cy5eo9z2")
@ -162,6 +166,55 @@ func TestAddPhotoToUserAlbums(t *testing.T) {
})
}
// TestAddPhotoToUserAlbumsConcurrentCreate exercises the related album behavior.
func TestAddPhotoToUserAlbumsConcurrentCreate(t *testing.T) {
_ = Db().Where("album_title = ?", "ConcurrencyTestAlbum").Unscoped().Delete(&Album{})
photos := []string{
PhotoFixtures.Get("Photo01").PhotoUID,
PhotoFixtures.Get("Photo02").PhotoUID,
PhotoFixtures.Get("Photo03").PhotoUID,
}
start := make(chan struct{})
var wg sync.WaitGroup
results := make(chan error, len(photos))
for _, uid := range photos {
wg.Add(1)
go func(photoUID string) {
defer wg.Done()
<-start
results <- AddPhotoToUserAlbums(photoUID, []string{"ConcurrencyTestAlbum"}, sortby.Oldest, OwnerUnknown)
}(uid)
}
close(start)
wg.Wait()
close(results)
for err := range results {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
var albums []Album
if err := Db().Where("album_title = ?", "ConcurrencyTestAlbum").Find(&albums).Error; err != nil {
t.Fatal(err)
}
if len(albums) != 1 {
t.Fatalf("expected a single album, got %d", len(albums))
}
var relationCount int
if err := Db().Table(PhotoAlbum{}.TableName()).Where("album_uid = ?", albums[0].AlbumUID).Count(&relationCount).Error; err != nil {
t.Fatal(err)
}
assert.Equal(t, len(photos), relationCount)
}
// TestNewAlbum exercises the related album behavior.
func TestNewAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewAlbum("Christmas 2018", AlbumManual)
@ -184,6 +237,7 @@ func TestNewAlbum(t *testing.T) {
})
}
// TestNewUserAlbum exercises the related album behavior.
func TestNewUserAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewUserAlbum("Christmas 2024", AlbumManual, "", "uqxqg7i1kperxvu7")
@ -193,6 +247,7 @@ func TestNewUserAlbum(t *testing.T) {
})
}
// TestNewFolderAlbum exercises the related album behavior.
func TestNewFolderAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewFolderAlbum("Dogs", "dogs", "label:dog")
@ -208,6 +263,7 @@ func TestNewFolderAlbum(t *testing.T) {
})
}
// TestNewMomentsAlbum exercises the related album behavior.
func TestNewMomentsAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewMomentsAlbum("Dogs", "dogs", "label:dog")
@ -223,6 +279,7 @@ func TestNewMomentsAlbum(t *testing.T) {
})
}
// TestNewStateAlbum exercises the related album behavior.
func TestNewStateAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewStateAlbum("Dogs", "dogs", "label:dog")
@ -238,6 +295,7 @@ func TestNewStateAlbum(t *testing.T) {
})
}
// TestNewMonthAlbum exercises the related album behavior.
func TestNewMonthAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewMonthAlbum("Dogs", "dogs", 2020, 7)
@ -255,6 +313,7 @@ func TestNewMonthAlbum(t *testing.T) {
})
}
// TestFindMonthAlbum exercises the related album behavior.
func TestFindMonthAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
result := FindMonthAlbum(2021, 9)
@ -277,6 +336,7 @@ func TestFindMonthAlbum(t *testing.T) {
})
}
// TestFindAlbumBySlug exercises the related album behavior.
func TestFindAlbumBySlug(t *testing.T) {
t.Run("Success", func(t *testing.T) {
result := FindAlbumBySlug("holiday-2030", AlbumManual)
@ -314,6 +374,7 @@ func TestFindAlbumBySlug(t *testing.T) {
})
}
// TestFindAlbumByAttr exercises the related album behavior.
func TestFindAlbumByAttr(t *testing.T) {
t.Run("FindByFilter", func(t *testing.T) {
result := FindAlbumByAttr([]string{}, []string{"path:\"1990/04\" public:true"}, AlbumFolder)
@ -345,6 +406,7 @@ func TestFindAlbumByAttr(t *testing.T) {
})
}
// TestFindFolderAlbum exercises the related album behavior.
func TestFindFolderAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := FindFolderAlbum("1990/04")
@ -374,6 +436,7 @@ func TestFindFolderAlbum(t *testing.T) {
})
}
// TestFindAlbum exercises the related album behavior.
func TestFindAlbum(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := AlbumFixtures.Get("christmas2030")
@ -430,6 +493,7 @@ func TestFindAlbum(t *testing.T) {
})
}
// TestAlbum_Find exercises the related album behavior.
func TestAlbum_Find(t *testing.T) {
t.Run("ExistingAlbum", func(t *testing.T) {
a := Album{AlbumUID: "as6sg6bitoga0004"}
@ -454,6 +518,7 @@ func TestAlbum_Find(t *testing.T) {
})
}
// TestAlbum_String exercises the related album behavior.
func TestAlbum_String(t *testing.T) {
t.Run("ReturnSlug", func(t *testing.T) {
album := Album{
@ -493,6 +558,7 @@ func TestAlbum_String(t *testing.T) {
})
}
// TestAlbum_IsMoment exercises the related album behavior.
func TestAlbum_IsMoment(t *testing.T) {
t.Run("False", func(t *testing.T) {
album := Album{
@ -514,6 +580,7 @@ func TestAlbum_IsMoment(t *testing.T) {
})
}
// TestAlbum_SetTitle exercises the related album behavior.
func TestAlbum_SetTitle(t *testing.T) {
t.Run("ValidName", func(t *testing.T) {
album := NewAlbum("initial name", AlbumManual)
@ -550,6 +617,7 @@ is an oblate spheroid.`
})
}
// TestAlbum_SetLocation exercises the related album behavior.
func TestAlbum_SetLocation(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := Album{}
@ -577,6 +645,7 @@ func TestAlbum_SetLocation(t *testing.T) {
})
}
// TestAlbum_UpdateTitleAndLocation exercises the related album behavior.
func TestAlbum_UpdateTitleAndLocation(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := Album{ID: 12345, AlbumUID: "as6sg6bxpogaakj6"}
@ -655,6 +724,7 @@ func TestAlbum_UpdateTitleAndLocation(t *testing.T) {
})
}
// TestAlbum_UpdateTitleAndState exercises the related album behavior.
func TestAlbum_UpdateTitleAndState(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewAlbum("Any State", AlbumState)
@ -734,6 +804,7 @@ func TestAlbum_UpdateTitleAndState(t *testing.T) {
})
}
// TestAlbum_SaveForm exercises the related album behavior.
func TestAlbum_SaveForm(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewAlbum("Old Name", AlbumManual)
@ -763,6 +834,7 @@ func TestAlbum_SaveForm(t *testing.T) {
})
}
// TestAlbum_Update exercises the related album behavior.
func TestAlbum_Update(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewAlbum("Test Title", AlbumManual)
@ -791,6 +863,7 @@ func TestAlbum_Update(t *testing.T) {
})
}
// TestAlbum_Updates exercises the related album behavior.
func TestAlbum_Updates(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewAlbum("Test Title", AlbumManual)
@ -819,6 +892,7 @@ func TestAlbum_Updates(t *testing.T) {
})
}
// TestAlbum_UpdateFolder exercises the related album behavior.
func TestAlbum_UpdateFolder(t *testing.T) {
t.Run("Success", func(t *testing.T) {
a := Album{ID: 99999, AlbumUID: "as6sg6bitogaaxxx"}
@ -863,6 +937,7 @@ func TestAlbum_UpdateFolder(t *testing.T) {
})
}
// TestAlbum_Save exercises the related album behavior.
func TestAlbum_Save(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := AlbumFixtures.Get("christmas2030")
@ -880,6 +955,7 @@ func TestAlbum_Save(t *testing.T) {
})
}
// TestAlbum_Create exercises the related album behavior.
func TestAlbum_Create(t *testing.T) {
t.Run("Album", func(t *testing.T) {
album := Album{
@ -927,6 +1003,7 @@ func TestAlbum_Create(t *testing.T) {
})
}
// TestAlbum_DeletePermanently exercises the related album behavior.
func TestAlbum_DeletePermanently(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := NewAlbum("Christmas 2018", AlbumManual)
@ -956,6 +1033,7 @@ func TestAlbum_DeletePermanently(t *testing.T) {
})
}
// TestAlbum_DeleteRestore exercises the related album behavior.
func TestAlbum_DeleteRestore(t *testing.T) {
t.Run("DeleteAndRestore", func(t *testing.T) {
album := NewAlbum("Test Title", AlbumManual)
@ -1034,6 +1112,7 @@ func TestAlbum_DeleteRestore(t *testing.T) {
}
// TestAlbum_Title exercises the related album behavior.
func TestAlbum_Title(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := Album{
@ -1046,6 +1125,7 @@ func TestAlbum_Title(t *testing.T) {
})
}
// TestAlbum_ZipName exercises the related album behavior.
func TestAlbum_ZipName(t *testing.T) {
t.Run("ChristmasNum2030Zip", func(t *testing.T) {
album := AlbumFixtures.Get("christmas2030")
@ -1061,6 +1141,7 @@ func TestAlbum_ZipName(t *testing.T) {
})
}
// TestAlbum_AddPhotos exercises the related album behavior.
func TestAlbum_AddPhotos(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := Album{
@ -1130,6 +1211,7 @@ func TestAlbum_AddPhotos(t *testing.T) {
})
}
// TestAlbum_RemovePhotos exercises the related album behavior.
func TestAlbum_RemovePhotos(t *testing.T) {
t.Run("Success", func(t *testing.T) {
album := Album{
@ -1189,6 +1271,7 @@ func TestAlbum_RemovePhotos(t *testing.T) {
})
}
// TestAlbum_Links exercises the related album behavior.
func TestAlbum_Links(t *testing.T) {
t.Run("OneResult", func(t *testing.T) {
album := AlbumFixtures.Get("christmas2030")

View file

@ -24,3 +24,49 @@ func (m Photos) UIDs() []string {
return result
}
type PhotoSet struct {
order []string
items map[string]*Photo
idx map[string]int // UID -> index in order
}
func NewPhotoSet() *PhotoSet {
return &PhotoSet{
order: make([]string, 0),
items: make(map[string]*Photo),
idx: make(map[string]int),
}
}
func (s *PhotoSet) Add(r *Photo) {
uid := r.GetUID()
if _, exists := s.items[uid]; !exists {
s.order = append(s.order, uid)
s.idx[uid] = len(s.order) - 1
}
s.items[uid] = r
}
func (s *PhotoSet) Delete(uid string) {
i, ok := s.idx[uid]
if !ok {
return
}
lastIdx := len(s.order) - 1
lastUID := s.order[lastIdx]
// swap removed element with last
s.order[i] = lastUID
s.idx[lastUID] = i
// shrink slice
s.order = s.order[:lastIdx]
delete(s.idx, uid)
delete(s.items, uid)
}

View file

@ -13,7 +13,7 @@ import (
// PhotoByID returns a Photo based on the ID.
func PhotoByID(photoID uint64) (photo entity.Photo, err error) {
if err := UnscopedDb().Where("id = ?", photoID).
if err = UnscopedDb().Where("id = ?", photoID).
Preload("Labels", func(db *gorm.DB) *gorm.DB {
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
}).
@ -33,7 +33,7 @@ func PhotoByID(photoID uint64) (photo entity.Photo, err error) {
// PhotoByUID returns a Photo based on the UID.
func PhotoByUID(photoUID string) (photo entity.Photo, err error) {
if err := UnscopedDb().Where("photo_uid = ?", photoUID).
if err = UnscopedDb().Where("photo_uid = ?", photoUID).
Preload("Labels", func(db *gorm.DB) *gorm.DB {
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
}).
@ -53,7 +53,45 @@ func PhotoByUID(photoUID string) (photo entity.Photo, err error) {
// PhotoPreloadByUID returns a Photo based on the UID with all dependencies preloaded.
func PhotoPreloadByUID(photoUID string) (photo entity.Photo, err error) {
if err := UnscopedDb().Where("photo_uid = ?", photoUID).
if err = preloadPhotoAssociations(UnscopedDb().Where("photo_uid = ?", photoUID)).
First(&photo).Error; err != nil {
return photo, err
}
photo.PreloadMany()
return photo, nil
}
// PhotoPreloadByUIDs returns photos for the provided UIDs with supporting associations preloaded.
// The call de-duplicates the UID list so callers can forward selection arrays directly without
// incurring redundant queries.
func PhotoPreloadByUIDs(photoUIDs []string) (entity.Photos, error) {
uids := uniqueUIDs(photoUIDs)
photos := entity.Photos{}
if len(uids) == 0 {
return photos, nil
}
if err := preloadPhotoAssociations(UnscopedDb().Where("photo_uid IN (?)", uids)).
Find(&photos).Error; err != nil {
return photos, err
}
for _, photo := range photos {
if photo == nil {
continue
}
photo.PreloadMany()
}
return photos, nil
}
// preloadPhotoAssociations applies the eager-load scope that keeps PhotoPreload helpers consistent.
func preloadPhotoAssociations(db *gorm.DB) *gorm.DB {
return db.
Preload("Labels", func(db *gorm.DB) *gorm.DB {
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
}).
@ -63,14 +101,30 @@ func PhotoPreloadByUID(photoUID string) (photo entity.Photo, err error) {
Preload("Details").
Preload("Place").
Preload("Cell").
Preload("Cell.Place").
First(&photo).Error; err != nil {
return photo, err
Preload("Cell.Place")
}
// uniqueUIDs normalizes and de-duplicates selection lists so callers can reuse them as-is.
func uniqueUIDs(uids []string) []string {
if len(uids) == 0 {
return nil
}
photo.PreloadMany()
result := make([]string, 0, len(uids))
seen := make(map[string]struct{}, len(uids))
return photo, nil
for _, uid := range uids {
if uid == "" {
continue
}
if _, ok := seen[uid]; ok {
continue
}
seen[uid] = struct{}{}
result = append(result, uid)
}
return result
}
// MissingPhotos returns photo entities without existing files.

View file

@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/pkg/rnd"
)
// TestPhotoByID validates photo query behavior.
func TestPhotoByID(t *testing.T) {
t.Run("PhotoFound", func(t *testing.T) {
result, err := PhotoByID(1000000)
@ -25,6 +26,7 @@ func TestPhotoByID(t *testing.T) {
})
}
// TestPhotoByUID validates photo query behavior.
func TestPhotoByUID(t *testing.T) {
t.Run("PhotoFound", func(t *testing.T) {
result, err := PhotoByUID("ps6sg6be2lvl0y12")
@ -40,6 +42,7 @@ func TestPhotoByUID(t *testing.T) {
})
}
// TestPreloadPhotoByUID validates photo query behavior.
func TestPreloadPhotoByUID(t *testing.T) {
t.Run("PhotoFound", func(t *testing.T) {
result, err := PhotoPreloadByUID("ps6sg6be2lvl0y12")
@ -55,6 +58,51 @@ func TestPreloadPhotoByUID(t *testing.T) {
})
}
// TestPhotoPreloadByUIDs validates photo query behavior.
func TestPhotoPreloadByUIDs(t *testing.T) {
t.Run("Multiple", func(t *testing.T) {
uids := []string{"ps6sg6be2lvl0y12", "ps6sg6be2lvl0y25", "ps6sg6be2lvl0y12"}
photos, err := PhotoPreloadByUIDs(uids)
if err != nil {
t.Fatal(err)
}
if len(photos) != 2 {
t.Fatalf("expected two unique photos, got %d", len(photos))
}
photoMap := make(map[string]*entity.Photo, len(photos))
for _, p := range photos {
if p == nil {
continue
}
photoMap[p.PhotoUID] = p
}
first := photoMap["ps6sg6be2lvl0y12"]
if first == nil {
t.Fatalf("expected photo ps6sg6be2lvl0y12 to be preloaded")
}
assert.Greater(t, len(first.Files), 0)
assert.True(t, first.CameraID > 0)
second := photoMap["ps6sg6be2lvl0y25"]
if second == nil {
t.Fatalf("expected photo ps6sg6be2lvl0y25 to be preloaded")
}
assert.Greater(t, len(second.Labels), 0)
})
t.Run("Empty", func(t *testing.T) {
photos, err := PhotoPreloadByUIDs(nil)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 0, len(photos))
})
}
// TestMissingPhotos validates photo query behavior.
func TestMissingPhotos(t *testing.T) {
result, err := MissingPhotos(15, 0)
@ -65,6 +113,7 @@ func TestMissingPhotos(t *testing.T) {
assert.LessOrEqual(t, 1, len(result))
}
// TestArchivedPhotos validates photo query behavior.
func TestArchivedPhotos(t *testing.T) {
results, err := ArchivedPhotos(15, 0)
@ -81,6 +130,7 @@ func TestArchivedPhotos(t *testing.T) {
}
}
// TestPhotosMetadataUpdate validates photo query behavior.
func TestPhotosMetadataUpdate(t *testing.T) {
interval := entity.MetadataUpdateInterval
result, err := PhotosMetadataUpdate(10, 0, time.Second, interval)
@ -92,6 +142,7 @@ func TestPhotosMetadataUpdate(t *testing.T) {
assert.IsType(t, entity.Photos{}, result)
}
// TestOrphanPhotos validates photo query behavior.
func TestOrphanPhotos(t *testing.T) {
result, err := OrphanPhotos()
@ -103,6 +154,7 @@ func TestOrphanPhotos(t *testing.T) {
}
// TODO How to verify?
// TestFixPrimaries validates photo query behavior.
func TestFixPrimaries(t *testing.T) {
t.Run("Success", func(t *testing.T) {
err := FixPrimaries()
@ -112,6 +164,7 @@ func TestFixPrimaries(t *testing.T) {
})
}
// TestFlagHiddenPhotos validates photo query behavior.
func TestFlagHiddenPhotos(t *testing.T) {
t.Run("Success", func(t *testing.T) {
// Set photo quality scores to -1 if files are missing.

View file

@ -18,6 +18,9 @@ func TestMain(m *testing.M) {
log.SetLevel(logrus.TraceLevel)
event.AuditLog = log
// Remove temporary SQLite files before running the tests.
fs.PurgeTestDbFiles(".", false)
c := config.TestConfig()
defer c.CloseDb()

View file

@ -0,0 +1,200 @@
## PhotoPrism — Batch Edit Package
**Last Updated:** November 17, 2025
### Overview
Batch editing allows signed-in users to update the metadata, albums, and labels of multiple photos without having to load and edit each one individually.
The `internal/photoprism/batch` package implements the form schema (`PhotosForm`), validation helpers, album/label mutation helpers, and persistence functions (`SavePhotos`) that power the `/api/v1/batch/photos/edit` endpoint. It exists so the API can keep responses consistent with the UI: mixed values stay round-trippable, add/remove operations track intent, and only changed columns hit the database.
#### Context & Constraints
- Community requests such as [Issue #271](https://github.com/photoprism/photoprism/issues/271) emphasized the need to bulk-edit core metadata (location, time zone, titles) instead of repeating the same change photo by photo.
- [PR #5324](https://github.com/photoprism/photoprism/pull/5324) introduced the modern batch dialog, chip controls, and validation rules that this package still serves.
- Batch edits run inside regular API workers; there is no dedicated job queue. We therefore optimize for O(n) database work across selected photos, avoid global locks, and offload heavy recomputation to existing workers (meta, labels, search index).
- Frontend components expect round-trip metadata even for fields that are not yet editable (ISO, focal length, copyright, etc.), so the form structs intentionally contain more data than the dialog renders.
#### Goals
- Provide a single schema (`PhotosForm`) for reading and writing mixed selections.
- Guarantee that album/label updates obey ACLs and deduplicate creations, even when multiple requests fire concurrently.
- Minimize writes by only persisting changed columns and deferring derived work (labels, keyword re-indexing) to background workers.
- Return refreshed photo models so the UI can immediately render the persisted state without extra queries.
#### Non-Goals
- Defining frontend validation or presentation; Vue components own layout, tooltips, and translations.
- Replacing specialized batch routes (`batch/photos/delete`, `…/archive`, etc.). This package focuses on metadata edits only.
- Managing worker scheduling. We simply mark `checked_at = NULL` so the metadata worker decides when to reprocess.
### Architecture & Request Flows
1. The router registers `BatchPhotosEdit` (`internal/api/batch_photos_edit.go`) under `/api/v1/batch/photos/edit`.
2. The handler authenticates via `Auth(..., acl.ResourcePhotos, acl.ActionUpdate)` before accepting JSON payloads shaped like `batch.PhotosRequest { photos: [], values: {} }`.
3. The handler always reuses the ordered `search.BatchPhotos` results when serializing the `models` array so every response mirrors the original selection and exposes the full `search.Photo` schema (thumbnail hashes, files, etc.) required by the lightbox.
4. After persisting updates, the handler issues a follow-up `query.PhotoPreloadByUIDs` call so `batch.PrepareAndSavePhotos` gets hydrated entities for album/label mutations without disrupting the frontend-facing payload.
5. `batch.PrepareAndSavePhotos` iterates over the preloaded entities, applies requested album/label changes, builds `PhotoSaveRequest` instances via `batch.NewPhotoSaveRequest`, and persists the updates before returning a summary (requests, results, updated count) to the API layer.
6. `SavePhotos` (invoked by the helper) loops once per request, updates only the columns that changed, clears `checked_at`, touches `edited_at`, and queues `entity.UpdateCountsAsync()` once if any photo saved.
7. Refreshed entities are converted back to `search.PhotoResults`, and `NewPhotosFormWithEntities` rebuilds the response form so the frontend sees the committed mixed-state snapshot.
### Batch Edit API Endpoint
`POST /api/v1/batch/photos/edit` accepts a `PhotosRequest` payload and returns the refreshed photo models plus a `PhotosForm` snapshot. All fields follow JSON casing from `internal/photoprism/batch/request.go` and `photos.go`.
| Field | Type | Examples | Notes |
|-------------------------|------------------|--------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| `photos` | `string[]` | `['pq1z9t3', 'px4y2k0']` | Required. Contains `PhotoUID` values selected in the UI. Empty lists are rejected with `400`. |
| `values` | `PhotosForm` | `{ "Title": { "action": "update", "value": "Vacation" } }` | Optional. When omitted, the endpoint only loads the selection + aggregated form data. |
| `values.Albums.items[]` | `Items` entry | `{ "value": "ab1c", "title": "Favorites", "action": "add" }` | Action must be `add`/`remove`; `title` is used to create albums when `value` is empty. |
| `values.Labels.items[]` | `Items` entry | `{ "value": "lb2d", "action": "remove" }` | Removing requires a valid label UID. Adds accept either UID or plain title. |
| `values.TimeZone` | `String` wrapper | `{ "value": "Europe/Berlin", "action": "update" }` | Paired with `Day/Month/Year` to recompute `TakenAt` and `TakenAtLocal`. |
| `values.DetailsSubject` | `String` wrapper | `{ "action": "remove" }` | Setting `action="remove"` clears the field without needing `value`. |
#### Request Example
```json
{
"photos": ["pt1abcd", "pt2efgh"],
"values": {
"Title": { "value": "Sunset Cruise", "action": "update" },
"Caption": { "action": "remove" },
"TimeZone": { "value": "America/Los_Angeles", "action": "update" },
"Day": { "value": 14, "action": "update" },
"Month": { "value": 11, "action": "update" },
"Year": { "value": 2025, "action": "update" },
"Albums": {
"action": "update",
"items": [
{ "title": "Trips 2025", "action": "add" },
{ "value": "abcf1234", "action": "remove" }
]
},
"Labels": {
"action": "update",
"items": [
{ "value": "lbtravel", "action": "add" },
{ "value": "lbbeta", "action": "remove" }
]
}
}
}
```
#### Response Example
```json
{
"models": [
{
"UID": "pt1abcd",
"Title": "Sunset Cruise",
"Favorite": true,
"Albums": [
{ "UID": "trips25", "Title": "Trips 2025" }
],
"Labels": [
{ "UID": "lbtravel", "Name": "Travel" }
]
}
],
"values": {
"Title": { "value": "Sunset Cruise", "mixed": false, "action": "update" },
"Caption": { "value": "", "mixed": false, "action": "remove" },
"Albums": {
"action": "update",
"mixed": false,
"items": [
{ "value": "trips25", "title": "Trips 2025", "mixed": false, "action": "none" }
]
}
}
}
```
### Frontend Integration
The SPA consumes the endpoint through a dedicated REST model, dialog component, and Vitest suites.
- **REST Model**: `frontend/src/model/batch.js` exports the `Batch` class, which encapsulates the `/batch/photos/edit` POST calls, manages hydrated `Photo` instances, tracks the current selection, and ensures mixed-value defaults match the backend schema.
- **Vue Components**: `frontend/src/component/photo/batch-edit.vue` renders the dialog, binding the models `values` to chips, combo boxes, and toggles. It is mounted via `frontend/src/component/dialogs.vue`, which exposes `<p-photo-batch-edit>` so any view can trigger batch edits.
- **Vitest Coverage**: `frontend/tests/vitest/model/batch.test.js` mocks Axios to verify that the model posts the correct payloads, updates cached photos, and handles no-op responses. `frontend/tests/vitest/component/photo/batch-edit.test.js` renders the dialog with Vue Test Utils to confirm field bindings, validation flows, and selection toggling behavior.
### Gating & Configuration
- ACL: only sessions allowed to `update` the `photos` resource may call this endpoint. That includes administrators and contributors with write access; read-only tokens fail early.
- Selection limits: `search.BatchPhotos` caps the request using `form.SearchPhotos.MaxResults` (default 5,000) to prevent runaway updates, and the ordered list returned there is reused verbatim for the API response so we avoid desynchronizing the frontend selection.
- Workers: clearing `CheckedAt` ensures the metadata worker (`internal/workers/meta`) and downstream indexers revisit the files within the configured worker interval (default 1020 minutes).
- Environment flags: standard safety toggles (`PHOTOPRISM_READONLY`, maintenance mode, etc.) still apply because the handler runs in the main API process.
### Supported Fields & Values
PhotosForm currently carries:
- Core descriptive metadata: title, caption, type, favorite/private flags, scan/panorama toggles.
- Temporal data: exact timestamps, local offsets, broken-out year/month/day, and time zone identifiers.
- Location fields: latitude, longitude, altitude, ISO country, derived place/cell IDs (updated via `UpdateLocation`).
- Equipment identifiers: camera and lens IDs/serials for future UI expansion.
- Details block: subject, artist, copyright, license, keywords (ensuring context from Issue #271 remains editable once the UI exposes the rows).
- Albums & labels as `Items` lists, with `Mixed` markers and per-item actions.
Each field embeds one of the typed wrappers (`String`, `Bool`, `Time`, `Int`, etc.) so the UI knows whether a value is mixed, unchanged, updated, or removed.
### Overriding Values with Sources & Priorities
- `Action` enums (`none`, `update`, `add`, `remove`) describe intent. Strings treat `remove` the same as `update` plus empty values, allowing the backend to wipe titles/captions clean.
- Source columns (`TitleSrc`, `CaptionSrc`, `TypeSrc`, `PlaceSrc`, details `*_src`) keep track of provenance. `SavePhotos` updates them whenever batch edits win over prior metadata (EXIF, AI, manual, etc.).
- Album & label updates respect UID validation: `ApplyAlbums` verifies `PhotoUID` / `AlbumUID`, creates albums by title when needed, and delegates to `entity.AddPhotoToAlbums`, which now uses per-album keyed locks to avoid blocking unrelated requests.
- Label writes reuse existing `PhotoLabel` rows when possible, force 100% confidence for manual/batch additions, and demote AI suggestions by setting `uncertainty = 100` when users explicitly remove them.
- Keyword keywords stay consistent because label removals call `photo.RemoveKeyword` and `SaveDetails` immediately, while location edits append unique place keywords via `txt.UniqueWords`.
### Performance & Concurrency
- `SavePhotos` only writes dirty columns and updates `photo_details` rows separately, reducing contention and avoiding `entity.SavePhotoForm`s per-photo cache busts. The API keeps reusing the ordered `search.BatchPhotos` result for serialization, accepting the extra `query.PhotoPreloadByUIDs` call (post-save) until the lightbox can consume entity-only responses or leverage the ordered list helper in `pkg/list/ordered` for a unified flow.
- Batch responses reuse the same hydrated entities for both persistence and response rendering, so even selections with hundreds of photos issue a constant number of queries.
- Album mutations leverage `entity.lockAlbumKey()` (per-album mutex) so two batches editing disjoint albums proceed in parallel instead of waiting on the global lock used before PR #5324s follow-up work.
- Label operations operate on preloaded associations (`indexPhotoLabels`) to avoid hitting the join table repeatedly.
- Background costs (keyword indexing, metadata regeneration) are deferred: clearing `CheckedAt` lets workers refresh derived data asynchronously, and `entity.UpdateCountsAsync()` runs once per batch regardless of size.
### Known Issues & Limitations
- UI coverage still lags the schema: EXIF controls such as ISO/f-number remain hidden, so users cannot yet set them even though backend transport exists (tracked in Issue #271).
- The endpoint assumes all selected photos are still readable; deleted originals during a batch run lead to warnings and skipped saves rather than hard failures.
- Keyword synchronization after label removal is best-effort; if `SaveDetails()` fails, the UI might display stale keywords until the next background refresh.
### Observability & Testing
- **Unit Tests**
- `internal/photoprism/batch/actions_test.go` validates album/label mutations, UID validation, and keyword handling.
- `internal/photoprism/batch/convert_test.go` and `photos_test.go` cover form aggregation and mixed-value detection.
- `internal/photoprism/batch/datelogic_test.go` ensures cross-field dependencies (local time vs. UTC) stay consistent.
- `internal/photoprism/batch/save_test.go` exercises partial updates, detail edits, `CheckedAt` resets, and the `PreparePhotoSaveRequests` / `PrepareAndSavePhotos` helpers.
- `internal/api/batch_photos_edit_test.go` provides end-to-end coverage for response envelopes (`SuccessNoChange`, `SuccessRemoveValues`, etc.).
- **Logging**
- The package uses the shared `event.Log` logger. Debug logs trace selections, album/label changes, and dirty-field sets; warnings/errors surface failed queries so operators can inspect database health.
- **Metrics & Alerts**
- The API shares the `/api/v1/metrics` Prometheus endpoint; batch edits increment the standard HTTP counters/latencies. Consider dashboarding 5xx/4xx spikes for `/batch/photos/edit` if you rely heavily on automation.
### Documentation & References
- <https://docs.photoprism.app/developer-guide/api/> — API Endpoints & Authentication
- <https://docs.photoprism.dev/> — Swagger REST API Documentation
- [GitHub Issue #271: Add batch edit dialog to change the metadata of multiple pictures](https://github.com/photoprism/photoprism/issues/271)
- [GitHub PR #5324: Implements #271 by adding a batch edit dialog and API endpoint](https://github.com/photoprism/photoprism/pull/5324)
### Code Map
- `batch.go` — package doc + logger bindings.
- `request.go` / `response.go` — transport structs for the API payload/response.
- `photos.go` — form aggregation from `search.PhotoResults` and bulk selection helpers.
- `convert.go` — translates `PhotosForm` into `form.Photo` instances for persistence.
- `actions.go` — album/label mutation helpers shared across API endpoints.
- `save.go` — differential persistence, `PreparePhotoSaveRequests`, `PrepareAndSavePhotos`, `NewPhotoSaveRequest`, `PhotoSaveRequest`, background worker triggers.
- `datelogic.go` — helpers for reconciling time zones and date parts when the UI only supplies partial values.
- `values.go` — typed wrappers for request fields (value + action + mixed flag).
### Next Steps
- [ ] Surface the dormant EXIF controls (ISO, focal length, lens/camera IDs) in the frontend and wire them to `PhotosForm` once the UI/UX is ready.
- [ ] Evaluate batching `ApplyLabels`/`ApplyAlbums` at the SQL level for very large selections while keeping validation safeguards.
- [ ] Document worker SLA guarantees (metadata refresh latency, label indexing) once observability data is available.

View file

@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/pkg/rnd"
)
// Action enumerates the supported batch operations such as add/remove/update.
type Action = string
const (
@ -92,6 +93,7 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
// Track if we changed anything to call SaveLabels once
changed := false
labelIndex := indexPhotoLabels(photo.Labels)
for _, it := range labels.Items {
switch it.Action {
@ -104,6 +106,7 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
// Try by UID first
var labelEntity *entity.Label
var err error
if it.Value != "" {
// If value is provided, validate it's a proper UID format
if !rnd.IsUID(it.Value, entity.LabelUID) {
@ -115,6 +118,7 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
return fmt.Errorf("label not found: %s", it.Value)
}
}
if labelEntity == nil && it.Title != "" {
// Create or find by title
labelEntity = entity.FirstOrCreateLabel(entity.NewLabel(it.Title, 0))
@ -128,22 +132,28 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
log.Debugf("batch: could not restore label %s: %s", labelEntity.LabelName, err)
}
pl := labelIndex[labelEntity.ID]
if pl == nil {
pl = entity.FirstOrCreatePhotoLabel(entity.NewPhotoLabel(photo.ID, labelEntity.ID, 0, entity.SrcBatch))
if pl == nil {
log.Errorf("batch: failed creating photo-label for photo %d and label %d", photo.ID, labelEntity.ID)
break
}
labelIndex[labelEntity.ID] = pl
}
// Ensure 100% confidence (uncertainty 0) and source 'batch'
if pl := entity.FirstOrCreatePhotoLabel(entity.NewPhotoLabel(photo.ID, labelEntity.ID, 0, entity.SrcBatch)); pl == nil {
log.Errorf("batch: failed creating photo-label for photo %d and label %d", photo.ID, labelEntity.ID)
} else {
// If it already existed with different values, update it
if pl.Uncertainty != 0 || pl.LabelSrc != entity.SrcBatch {
pl.Uncertainty = 0
pl.LabelSrc = entity.SrcBatch
if err := entity.Db().Save(pl).Error; err != nil {
log.Errorf("batch: update label to 100%% confidence failed: %s", err)
} else {
changed = true
}
if pl.Uncertainty != 0 || pl.LabelSrc != entity.SrcBatch {
pl.Uncertainty = 0
pl.LabelSrc = entity.SrcBatch
if err = entity.Db().Save(pl).Error; err != nil {
log.Errorf("batch: update label to 100%% confidence failed: %s", err)
} else {
changed = true
}
} else {
changed = true
}
case ActionRemove:
@ -157,19 +167,29 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
}
labelEntity, err := query.LabelByUID(it.Value)
if err != nil || labelEntity == nil || !labelEntity.HasID() {
return fmt.Errorf("label not found for removal: %s", it.Value)
}
if pl, err := query.PhotoLabel(photo.ID, labelEntity.ID); err != nil {
log.Debugf("batch: photo-label not found for removal: photo=%s label_id=%d", photo.PhotoUID, labelEntity.ID)
continue
} else if pl != nil {
pl := labelIndex[labelEntity.ID]
if pl == nil {
if cached, queryErr := query.PhotoLabel(photo.ID, labelEntity.ID); queryErr != nil {
log.Debugf("batch: photo-label not found for removal: photo=%s label_id=%d", photo.PhotoUID, labelEntity.ID)
continue
} else {
pl = cached
}
}
if pl != nil {
if (pl.LabelSrc == entity.SrcManual || pl.LabelSrc == entity.SrcBatch) && pl.Uncertainty < 100 {
if err := entity.Db().Delete(&pl).Error; err != nil {
log.Errorf("batch: delete label failed: %s", err)
} else {
log.Debugf("batch: deleted label: photo=%s label_id=%d", photo.PhotoUID, labelEntity.ID)
delete(labelIndex, labelEntity.ID)
changed = true
}
} else if pl.LabelSrc != entity.SrcManual && pl.LabelSrc != entity.SrcBatch {
@ -186,9 +206,11 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
log.Errorf("batch: save label failed: %s", err)
}
}
_ = photo.RemoveKeyword(labelEntity.LabelName)
// Persist updated keywords immediately so the change survives reloads
if err := photo.SaveDetails(); err != nil {
if err = photo.SaveDetails(); err != nil {
log.Debugf("batch: failed to save details after keyword removal: %s", err)
}
}
@ -203,11 +225,11 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
if changed {
// Reload photo to ensure in-memory labels reflect DB changes before saving derived fields
if reloaded, err := query.PhotoPreloadByUID(photo.PhotoUID); err == nil && reloaded.HasID() {
if err := (&reloaded).SaveLabels(); err != nil {
if err = (&reloaded).SaveLabels(); err != nil {
return err
}
} else {
if err := photo.SaveLabels(); err != nil {
if err = photo.SaveLabels(); err != nil {
return err
}
}
@ -215,3 +237,23 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
return nil
}
// indexPhotoLabels builds a PhotoLabel lookup map so ApplyLabels can reuse the associations that
// were already preloaded for the batch selection without re-querying the join table.
func indexPhotoLabels(labels entity.PhotoLabels) map[uint]*entity.PhotoLabel {
if len(labels) == 0 {
return map[uint]*entity.PhotoLabel{}
}
idx := make(map[uint]*entity.PhotoLabel, len(labels))
for i := range labels {
lbl := &labels[i]
if lbl == nil || lbl.LabelID == 0 {
continue
}
idx[lbl.LabelID] = lbl
}
return idx
}

View file

@ -1,29 +1,13 @@
package batch
import (
"os"
"testing"
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
)
func TestMain(m *testing.M) {
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
db := entity.InitTestDb(
os.Getenv("PHOTOPRISM_TEST_DRIVER"),
os.Getenv("PHOTOPRISM_TEST_DSN"))
defer db.Close()
code := m.Run()
os.Exit(code)
}
// TestApplyAlbums exercises batch action logic.
func TestApplyAlbums(t *testing.T) {
t.Run("AddPhotoToExistingAlbumByUID", func(t *testing.T) {
photo := entity.PhotoFixtures.Get("Photo01")
@ -224,13 +208,13 @@ func TestApplyAlbums(t *testing.T) {
},
}
err := ApplyAlbums(invalidPhotoUID, albums)
if err == nil {
if err := ApplyAlbums(invalidPhotoUID, albums); err == nil {
t.Error("expected error for invalid photo UID, but got none")
}
})
}
// TestApplyLabels exercises batch action logic.
func TestApplyLabels(t *testing.T) {
t.Run("AddExistingLabelByUID", func(t *testing.T) {
photo := entity.PhotoFixtures.Pointer("Photo06")
@ -248,6 +232,7 @@ func TestApplyLabels(t *testing.T) {
// Verify photo has the label with 100% confidence and batch source
var photoLabel entity.PhotoLabel
if err := entity.Db().Preload("Label").Where("photo_id = ? AND label_id = (SELECT id FROM labels WHERE label_uid = ?)", photo.ID, labelUID).First(&photoLabel).Error; err != nil {
t.Fatal(err)
}
@ -276,6 +261,7 @@ func TestApplyLabels(t *testing.T) {
// Verify label was created and added to photo
var label entity.Label
if err := entity.Db().Where("label_name = ?", labelTitle).First(&label).Error; err != nil {
t.Fatal(err)
}
@ -285,6 +271,7 @@ func TestApplyLabels(t *testing.T) {
}
var photoLabel entity.PhotoLabel
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&photoLabel).Error; err != nil {
t.Fatal(err)
}
@ -303,12 +290,14 @@ func TestApplyLabels(t *testing.T) {
// First add the label manually
photoLabel := entity.NewPhotoLabel(photo.ID, label.ID, 0, entity.SrcBatch)
if err := entity.Db().Create(&photoLabel).Error; err != nil {
t.Fatal(err)
}
// Verify label is on photo
var checkPhotoLabel entity.PhotoLabel
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&checkPhotoLabel).Error; err != nil {
t.Fatal(err)
}
@ -326,17 +315,52 @@ func TestApplyLabels(t *testing.T) {
// Verify label was removed (should be deleted from photos_labels)
var deletedLabel entity.PhotoLabel
err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&deletedLabel).Error
if err == nil {
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&deletedLabel).Error; err == nil {
t.Error("expected label to be deleted, but it was found")
}
})
t.Run("RemoveLabelWithPreloadedPhoto", func(t *testing.T) {
photo := entity.PhotoFixtures.Pointer("Photo17")
label := entity.LabelFixtures.Get("landscape")
entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).Delete(&entity.PhotoLabel{})
if err := entity.Db().Create(entity.NewPhotoLabel(photo.ID, label.ID, 0, entity.SrcManual)).Error; err != nil {
t.Fatal(err)
}
preloaded, err := query.PhotoPreloadByUID(photo.PhotoUID)
if err != nil {
t.Fatal(err)
}
if len(preloaded.Labels) == 0 {
t.Fatalf("expected preloaded labels for %s", photo.PhotoUID)
}
labels := Items{
Items: []Item{{Action: ActionRemove, Value: label.LabelUID}},
}
if err = ApplyLabels(&preloaded, labels); err != nil {
t.Fatal(err)
}
var deleted entity.PhotoLabel
if err = entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&deleted).Error; err == nil {
t.Fatal("expected label relation to be removed")
}
})
t.Run("RemoveAutoLabelSetsUncertaintyTo100", func(t *testing.T) {
photo := entity.PhotoFixtures.Pointer("Photo09")
label := entity.LabelFixtures.Get("cake")
// Add label with auto source (not manual/batch)
photoLabel := entity.NewPhotoLabel(photo.ID, label.ID, 15, entity.SrcImage)
if err := entity.Db().Create(&photoLabel).Error; err != nil {
t.Fatal(err)
}
@ -353,6 +377,7 @@ func TestApplyLabels(t *testing.T) {
// Verify label uncertainty was set to 100% (blocked)
var blockedLabel entity.PhotoLabel
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&blockedLabel).Error; err != nil {
t.Fatal(err)
}
@ -374,6 +399,7 @@ func TestApplyLabels(t *testing.T) {
// Add label with some uncertainty using FirstOrCreatePhotoLabel
photoLabel := entity.FirstOrCreatePhotoLabel(entity.NewPhotoLabel(photo.ID, label.ID, 50, entity.SrcImage))
if photoLabel == nil {
t.Fatal("failed to create photo label")
}
@ -400,6 +426,7 @@ func TestApplyLabels(t *testing.T) {
// Verify label confidence was updated
var updatedLabel entity.PhotoLabel
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&updatedLabel).Error; err != nil {
t.Fatal(err)
}
@ -420,12 +447,14 @@ func TestApplyLabels(t *testing.T) {
}
err := ApplyLabels(nil, labels)
if err == nil {
t.Error("expected error for nil photo")
}
emptyPhoto := &entity.Photo{}
err = ApplyLabels(emptyPhoto, labels)
if err == nil {
t.Error("expected error for empty photo")
}
@ -442,6 +471,7 @@ func TestApplyLabels(t *testing.T) {
}
err := ApplyLabels(photo, labels)
if err == nil {
t.Error("expected error when adding non-existing label by UID, but got none")
}
@ -457,6 +487,7 @@ func TestApplyLabels(t *testing.T) {
}
err := ApplyLabels(photo, labels)
if err == nil {
t.Error("expected error when adding label with invalid UID, but got none")
}
@ -500,6 +531,7 @@ func TestApplyLabels(t *testing.T) {
// This should not error, but should be a no-op
err := ApplyLabels(photo, labels)
if err != nil {
t.Errorf("expected no error for empty label items, but got: %v", err)
}
@ -514,6 +546,7 @@ func TestApplyLabels(t *testing.T) {
}
err := ApplyLabels(photo, labels)
if err == nil {
t.Error("expected error when both Value and Title are empty, but got none")
}
@ -532,8 +565,28 @@ func TestApplyLabels(t *testing.T) {
}
err := ApplyLabels(photo, labels)
if err == nil {
t.Error("expected error when removing label not assigned to photo, but got none")
}
})
}
// TestIndexPhotoLabels exercises batch action logic.
func TestIndexPhotoLabels(t *testing.T) {
labels := entity.PhotoLabels{
{LabelID: 11},
{LabelID: 0},
{LabelID: 22},
}
idx := indexPhotoLabels(labels)
if len(idx) != 2 {
t.Fatalf("expected 2 labels in index, got %d", len(idx))
}
if idx[11] == nil || idx[22] == nil {
t.Fatal("expected indexed labels to be present")
}
}

View file

@ -1,5 +1,8 @@
/*
Package batch provides batch editing forms, types and validation.
Package batch coordinates PhotoPrisms multi-photo edit workflow by defining
the PhotosForm schema, helper types for expressing add/remove actions, and the
validation / persistence helpers (ApplyAlbums, ApplyLabels, SavePhotos, etc.)
that the API layer uses to safely mutate selections of photos at once.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.

View file

@ -0,0 +1,37 @@
package batch
import (
"os"
"testing"
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/fs"
)
// TestMain configures shared state for the batch action tests.
func TestMain(m *testing.M) {
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
event.AuditLog = log
// Remove temporary SQLite files before running the tests.
fs.PurgeTestDbFiles(".", false)
c := config.TestConfig()
defer c.CloseDb()
get.SetConfig(c)
photoprism.SetConfig(c)
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View file

@ -3,6 +3,7 @@ package batch
import (
"sort"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/entity/search"
)
@ -47,9 +48,17 @@ type PhotosForm struct {
DetailsLicense String `json:"DetailsLicense,omitempty"`
}
// NewPhotosForm returns a new batch edit form instance
// initialized with values from the selected photos.
// NewPhotosForm returns a new batch edit form instance initialized with values from the
// selected photos. It falls back to reloading each photo individually.
func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
return NewPhotosFormWithEntities(photos, nil)
}
// NewPhotosFormWithEntities builds the batch edit form using the supplied photo selection and
// an optional map of preloaded entity.Photo instances that should be reused instead of issuing
// additional queries. When the map is nil or a photo is missing, the helper falls back to
// `query.PhotoPreloadByUID` so legacy call sites keep working.
func NewPhotosFormWithEntities(photos search.PhotoResults, preloaded map[string]*entity.Photo) *PhotosForm {
// Create a new batch edit form and initialize it
// with the values from the selected photos.
frm := &PhotosForm{
@ -57,6 +66,24 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
Labels: Items{Items: []Item{}, Mixed: false, Action: ActionNone},
}
getPhoto := func(uid string) *entity.Photo {
if uid == "" {
return nil
}
if preloaded != nil {
if p := preloaded[uid]; p != nil {
return p
}
}
if p, err := query.PhotoPreloadByUID(uid); err == nil && p.HasID() {
return &p
}
return nil
}
// Populate Albums and Labels from selected photos (no raw SQL; use preload helpers)
total := len(photos)
if total > 0 {
@ -64,10 +91,12 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
title string
cnt int
}
type labelAgg struct {
name string
cnt int
}
albumCount := map[string]albumAgg{}
labelCount := map[string]labelAgg{}
@ -75,10 +104,11 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
if sp.PhotoUID == "" {
continue
}
p, err := query.PhotoPreloadByUID(sp.PhotoUID)
if err != nil || !p.HasID() {
p := getPhoto(sp.PhotoUID)
if p == nil || !p.HasID() {
continue
}
// Albums on this photo
for _, a := range p.Albums {
if a.AlbumUID == "" || a.Deleted() {
@ -89,6 +119,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
v.cnt++
albumCount[a.AlbumUID] = v
}
// Labels on this photo (only visible ones: uncertainty < 100)
for _, pl := range p.Labels {
if pl.Uncertainty >= 100 || pl.Label == nil || !pl.Label.HasID() {
@ -108,6 +139,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
// Build Albums items
frm.Albums.Items = make([]Item, 0, len(albumCount))
anyAlbumMixed := false
for uid, agg := range albumCount {
mixed := agg.cnt > 0 && agg.cnt < total
if mixed {
@ -115,6 +147,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
}
frm.Albums.Items = append(frm.Albums.Items, Item{Value: uid, Title: agg.title, Mixed: mixed, Action: ActionNone})
}
// Sort shared-first (Mixed=false), then by Title alphabetically
sort.Slice(frm.Albums.Items, func(i, j int) bool {
if frm.Albums.Items[i].Mixed != frm.Albums.Items[j].Mixed {
@ -122,6 +155,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
}
return frm.Albums.Items[i].Title < frm.Albums.Items[j].Title
})
frm.Albums.Mixed = anyAlbumMixed
frm.Albums.Action = ActionNone
@ -135,6 +169,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
}
frm.Labels.Items = append(frm.Labels.Items, Item{Value: uid, Title: agg.name, Mixed: mixed, Action: ActionNone})
}
// Sort shared-first (Mixed=false), then by Title alphabetically
sort.Slice(frm.Labels.Items, func(i, j int) bool {
if frm.Labels.Items[i].Mixed != frm.Labels.Items[j].Mixed {
@ -142,6 +177,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
}
return frm.Labels.Items[i].Title < frm.Labels.Items[j].Title
})
frm.Labels.Mixed = anyLabelMixed
frm.Labels.Action = ActionNone
}

View file

@ -13,7 +13,15 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
func TestNewPhotosForm_FromJSON(t *testing.T) {
func TestNewPhotosForm(t *testing.T) {
t.Run("FromJSON", runNewPhotosFormFromJSON)
t.Run("FromFixturesAlbumsLabels", runNewPhotosFormFromFixtures)
t.Run("TwoPhotosMixedFlags1", runNewPhotosFormMixedFlags1)
t.Run("TwoPhotosMixedFlags2", runNewPhotosFormMixedFlags2)
}
// runNewPhotosFormFromJSON exercises PhotosForm behavior.
func runNewPhotosFormFromJSON(t *testing.T) {
var photos search.PhotoResults
dataFile := fs.Abs("./testdata/photos.json")
@ -74,7 +82,8 @@ func TestNewPhotosForm_FromJSON(t *testing.T) {
assert.Equal(t, true, frm.DetailsLicense.Mixed)
}
func TestNewPhotosForm_FromFixturesAlbumsLabels(t *testing.T) {
// runNewPhotosFormFromFixtures ensures preloaded fixtures behave.
func runNewPhotosFormFromFixtures(t *testing.T) {
// Ensure test config and fixtures/DB are initialized.
_ = config.TestConfig()
@ -97,7 +106,8 @@ func TestNewPhotosForm_FromFixturesAlbumsLabels(t *testing.T) {
}
}
func TestNewPhotosForm_TwoPhotosMixedFlags1(t *testing.T) {
// runNewPhotosFormMixedFlags1 captures mixed flag handling.
func runNewPhotosFormMixedFlags1(t *testing.T) {
photo1 := search.Photo{
ID: 111115411,
PhotoUID: "",
@ -185,7 +195,8 @@ func TestNewPhotosForm_TwoPhotosMixedFlags1(t *testing.T) {
assert.Equal(t, true, frm.PhotoYear.Mixed)
}
func TestNewPhotosForm_TwoPhotosMixedFlags2(t *testing.T) {
// runNewPhotosFormMixedFlags2 covers camera/lens variance.
func runNewPhotosFormMixedFlags2(t *testing.T) {
photo1 := search.Photo{
ID: 111115411,
PhotoUID: "",
@ -256,3 +267,25 @@ func TestNewPhotosForm_TwoPhotosMixedFlags2(t *testing.T) {
assert.Equal(t, 2020, frm.PhotoYear.Value)
assert.Equal(t, false, frm.PhotoYear.Mixed)
}
func TestNewPhotosFormWithEntities(t *testing.T) {
t.Run("FallsBack", runNewPhotosFormWithEntitiesFallsBack)
}
// runNewPhotosFormWithEntitiesFallsBack ensures helper falls back correctly.
func runNewPhotosFormWithEntitiesFallsBack(t *testing.T) {
_ = config.TestConfig()
photos := search.PhotoResults{
{PhotoUID: "pqkm36fjqvset9uz"},
{PhotoUID: "pqkm36fjqvset9uy"},
}
legacy := NewPhotosForm(photos)
withNil := NewPhotosFormWithEntities(photos, nil)
if assert.NotNil(t, legacy) && assert.NotNil(t, withNil) {
assert.Equal(t, legacy.PhotoTitle.Value, withNil.PhotoTitle.Value)
assert.Equal(t, len(legacy.Albums.Items), len(withNil.Albums.Items))
assert.Equal(t, len(legacy.Labels.Items), len(withNil.Labels.Items))
}
}

View file

@ -0,0 +1,386 @@
package batch
import (
"fmt"
"strings"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/txt"
)
// PhotoSaveRequest bundles the context required to persist a batch update for a single photo.
type PhotoSaveRequest struct {
Photo *entity.Photo
Form *form.Photo
Values *PhotosForm
}
// SaveBatchResult describes the outcome of PrepareAndSavePhotos so callers can publish events
// and build responses without re-walking the selection.
type SaveBatchResult struct {
Requests []*PhotoSaveRequest
Results []bool
Preloaded map[string]*entity.Photo
UpdatedCount int
SavedAny bool
}
// NewPhotoSaveRequest converts the batch values into a form.Photo and bundles it with the
// target entity so callers outside this package do not have to depend on ConvertToPhotoForm.
func NewPhotoSaveRequest(photo *entity.Photo, values *PhotosForm) (*PhotoSaveRequest, error) {
if values == nil {
return nil, fmt.Errorf("batch: values are required")
}
frm, err := ConvertToPhotoForm(photo, values)
if err != nil {
return nil, err
}
return &PhotoSaveRequest{Photo: photo, Form: frm, Values: values}, nil
}
// PreparePhotoSaveRequests converts the given selection into save requests, ensuring each photo
// entity is hydrated and album/label actions are applied once per selection. The returned map is
// the (possibly newly populated) preloaded set so callers can reuse it for response rendering.
func PreparePhotoSaveRequests(photos search.PhotoResults, preloaded map[string]*entity.Photo, values *PhotosForm) ([]*PhotoSaveRequest, map[string]*entity.Photo) {
if values == nil {
return nil, preloaded
}
if preloaded == nil {
preloaded = map[string]*entity.Photo{}
}
log.Debugf("batch: updating photo metadata for %d photos", len(photos))
saveRequests := make([]*PhotoSaveRequest, 0, len(photos))
for _, result := range photos {
photoID := result.PhotoUID
if photoID == "" {
continue
}
fullPhoto := preloaded[photoID]
if fullPhoto == nil {
loaded, err := query.PhotoPreloadByUID(photoID)
if err != nil {
log.Errorf("batch: failed to load photo %s: %s", photoID, err)
continue
}
fullPhoto = &loaded
preloaded[photoID] = fullPhoto
}
saveReq, err := NewPhotoSaveRequest(fullPhoto, values)
if err != nil {
log.Errorf("batch: failed to build save request for photo %s: %s", photoID, err)
continue
}
saveRequests = append(saveRequests, saveReq)
if values.Albums.Action == ActionUpdate {
if err := ApplyAlbums(photoID, values.Albums); err != nil {
log.Errorf("batch: failed to update albums for photo %s: %s", photoID, err)
}
}
if values.Labels.Action == ActionUpdate {
if err := ApplyLabels(fullPhoto, values.Labels); err != nil {
log.Errorf("batch: failed to update labels for photo %s: %s", photoID, err)
}
}
}
return saveRequests, preloaded
}
// PrepareAndSavePhotos hydrates the photo selection, applies album/label mutations, builds save
// requests, and persists the changes in one step. It returns a SaveBatchResult so callers can run
// follow-up work (events, cache flushes) without re-querying state.
func PrepareAndSavePhotos(photos search.PhotoResults, preloaded map[string]*entity.Photo, values *PhotosForm) (*SaveBatchResult, error) {
result := &SaveBatchResult{Preloaded: preloaded}
if values == nil {
if result.Preloaded == nil {
result.Preloaded = map[string]*entity.Photo{}
}
return result, nil
}
requests, preloaded := PreparePhotoSaveRequests(photos, result.Preloaded, values)
result.Requests = requests
result.Preloaded = preloaded
if len(requests) == 0 {
return result, nil
}
saveResults, err := SavePhotos(requests)
if err != nil {
return nil, err
}
result.Results = saveResults
for i, saved := range saveResults {
if saved {
result.UpdatedCount++
result.SavedAny = true
log.Debugf("batch: successfully updated photo %s", requests[i].Photo.PhotoUID)
}
}
log.Infof("batch: successfully updated %d out of %d photos", result.UpdatedCount, len(photos))
return result, nil
}
// SavePhotos persists the batch updates described by the provided requests while skipping the
// heavyweight per-photo maintenance performed by entity.SavePhotoForm. It only updates the
// columns that actually changed, flags the photo for background metadata refresh by clearing
// CheckedAt, and updates the shared counts once per batch instead of once per photo.
func SavePhotos(requests []*PhotoSaveRequest) ([]bool, error) {
results := make([]bool, len(requests))
anySaved := false
for i, req := range requests {
saved, err := savePhoto(req)
if err != nil {
return results, err
}
results[i] = saved
if saved {
anySaved = true
}
}
if anySaved {
entity.UpdateCountsAsync()
}
return results, nil
}
// savePhoto applies the batched form values to a single entity.Photo and writes
// only the changed columns back to the database.
func savePhoto(req *PhotoSaveRequest) (bool, error) {
if req == nil || req.Photo == nil || req.Form == nil || req.Values == nil {
return false, fmt.Errorf("batch: invalid save request")
}
p := req.Photo
formValues := req.Values
formData := req.Form
if !p.HasID() {
return false, fmt.Errorf("batch: photo has no id")
}
original := *p
details := p.GetDetails()
origDetails := *details
if shouldUpdateString(formValues.PhotoTitle) {
p.PhotoTitle = formData.PhotoTitle
p.TitleSrc = formData.TitleSrc
}
if shouldUpdateString(formValues.PhotoCaption) {
p.PhotoCaption = formData.PhotoCaption
p.CaptionSrc = formData.CaptionSrc
}
if formValues.PhotoType.Action == ActionUpdate {
p.PhotoType = formData.PhotoType
p.TypeSrc = formData.TypeSrc
}
if formValues.PhotoFavorite.Action == ActionUpdate {
p.PhotoFavorite = formValues.PhotoFavorite.Value
}
if formValues.PhotoPrivate.Action == ActionUpdate {
p.PhotoPrivate = formValues.PhotoPrivate.Value
}
if formValues.PhotoScan.Action == ActionUpdate {
p.PhotoScan = formValues.PhotoScan.Value
}
if formValues.PhotoPanorama.Action == ActionUpdate {
p.PhotoPanorama = formValues.PhotoPanorama.Value
}
timeChanged := formValues.PhotoDay.Action == ActionUpdate ||
formValues.PhotoMonth.Action == ActionUpdate ||
formValues.PhotoYear.Action == ActionUpdate ||
formValues.TimeZone.Action == ActionUpdate
if timeChanged {
p.PhotoYear = formData.PhotoYear
p.PhotoMonth = formData.PhotoMonth
p.PhotoDay = formData.PhotoDay
if formValues.TimeZone.Action == ActionUpdate {
p.TimeZone = formData.TimeZone
}
p.TakenAtLocal = formData.TakenAtLocal
p.TakenSrc = formData.TakenSrc
p.NormalizeValues()
if p.TimeZoneUTC() {
p.TakenAt = p.TakenAtLocal
} else {
p.TakenAt = p.GetTakenAt()
}
p.UpdateDateFields()
}
locationChanged := formValues.PhotoLat.Action == ActionUpdate ||
formValues.PhotoLng.Action == ActionUpdate ||
formValues.PhotoCountry.Action == ActionUpdate ||
formValues.PhotoAltitude.Action == ActionUpdate
if formValues.PhotoLat.Action == ActionUpdate {
p.PhotoLat = formValues.PhotoLat.Value
}
if formValues.PhotoLng.Action == ActionUpdate {
p.PhotoLng = formValues.PhotoLng.Value
}
if formValues.PhotoAltitude.Action == ActionUpdate {
p.PhotoAltitude = formValues.PhotoAltitude.Value
}
if formValues.PhotoCountry.Action == ActionUpdate {
p.PhotoCountry = formValues.PhotoCountry.Value
}
if locationChanged {
p.PlaceSrc = entity.SrcBatch
locKeywords, locLabels := p.UpdateLocation()
if len(locLabels) > 0 {
p.AddLabels(locLabels)
}
if len(locKeywords) > 0 {
words := txt.UniqueWords(txt.Words(details.Keywords))
words = append(words, locKeywords...)
details.Keywords = strings.Join(txt.UniqueWords(words), ", ")
}
}
if formValues.DetailsSubject.Action == ActionUpdate || formValues.DetailsSubject.Action == ActionRemove {
details.Subject = formData.Details.Subject
details.SubjectSrc = formData.Details.SubjectSrc
}
if formValues.DetailsArtist.Action == ActionUpdate || formValues.DetailsArtist.Action == ActionRemove {
details.Artist = formData.Details.Artist
details.ArtistSrc = formData.Details.ArtistSrc
}
if formValues.DetailsCopyright.Action == ActionUpdate || formValues.DetailsCopyright.Action == ActionRemove {
details.Copyright = formData.Details.Copyright
details.CopyrightSrc = formData.Details.CopyrightSrc
}
if formValues.DetailsLicense.Action == ActionUpdate || formValues.DetailsLicense.Action == ActionRemove {
details.License = formData.Details.License
details.LicenseSrc = formData.Details.LicenseSrc
}
updates := entity.Values{}
addUpdate := func(column string, changed bool, value interface{}) {
if changed {
updates[column] = value
}
}
addUpdate("photo_title", p.PhotoTitle != original.PhotoTitle, p.PhotoTitle)
addUpdate("title_src", p.TitleSrc != original.TitleSrc, p.TitleSrc)
addUpdate("photo_caption", p.PhotoCaption != original.PhotoCaption, p.PhotoCaption)
addUpdate("caption_src", p.CaptionSrc != original.CaptionSrc, p.CaptionSrc)
addUpdate("photo_type", p.PhotoType != original.PhotoType, p.PhotoType)
addUpdate("type_src", p.TypeSrc != original.TypeSrc, p.TypeSrc)
addUpdate("photo_favorite", p.PhotoFavorite != original.PhotoFavorite, p.PhotoFavorite)
addUpdate("photo_private", p.PhotoPrivate != original.PhotoPrivate, p.PhotoPrivate)
addUpdate("photo_scan", p.PhotoScan != original.PhotoScan, p.PhotoScan)
addUpdate("photo_panorama", p.PhotoPanorama != original.PhotoPanorama, p.PhotoPanorama)
addUpdate("photo_year", p.PhotoYear != original.PhotoYear, p.PhotoYear)
addUpdate("photo_month", p.PhotoMonth != original.PhotoMonth, p.PhotoMonth)
addUpdate("photo_day", p.PhotoDay != original.PhotoDay, p.PhotoDay)
addUpdate("time_zone", p.TimeZone != original.TimeZone, p.TimeZone)
addUpdate("taken_src", p.TakenSrc != original.TakenSrc, p.TakenSrc)
addUpdate("taken_at", !p.TakenAt.Equal(original.TakenAt), p.TakenAt)
addUpdate("taken_at_local", !p.TakenAtLocal.Equal(original.TakenAtLocal), p.TakenAtLocal)
addUpdate("photo_lat", p.PhotoLat != original.PhotoLat, p.PhotoLat)
addUpdate("photo_lng", p.PhotoLng != original.PhotoLng, p.PhotoLng)
addUpdate("photo_altitude", p.PhotoAltitude != original.PhotoAltitude, p.PhotoAltitude)
addUpdate("photo_country", p.PhotoCountry != original.PhotoCountry, p.PhotoCountry)
addUpdate("place_id", p.PlaceID != original.PlaceID, p.PlaceID)
addUpdate("cell_id", p.CellID != original.CellID, p.CellID)
addUpdate("place_src", p.PlaceSrc != original.PlaceSrc, p.PlaceSrc)
detailUpdates := entity.Values{}
if details.Subject != origDetails.Subject {
detailUpdates["subject"] = details.Subject
}
if details.SubjectSrc != origDetails.SubjectSrc {
detailUpdates["subject_src"] = details.SubjectSrc
}
if details.Artist != origDetails.Artist {
detailUpdates["artist"] = details.Artist
}
if details.ArtistSrc != origDetails.ArtistSrc {
detailUpdates["artist_src"] = details.ArtistSrc
}
if details.Copyright != origDetails.Copyright {
detailUpdates["copyright"] = details.Copyright
}
if details.CopyrightSrc != origDetails.CopyrightSrc {
detailUpdates["copyright_src"] = details.CopyrightSrc
}
if details.License != origDetails.License {
detailUpdates["license"] = details.License
}
if details.LicenseSrc != origDetails.LicenseSrc {
detailUpdates["license_src"] = details.LicenseSrc
}
if details.Keywords != origDetails.Keywords {
detailUpdates["keywords"] = details.Keywords
}
if len(updates) == 0 && len(detailUpdates) == 0 {
return false, nil
}
log.Debugf("batch: saving photo %s with updates=%v details=%v", p.PhotoUID, updates, detailUpdates)
edited := entity.Now()
updates["edited_at"] = edited
p.EditedAt = &edited
updates["checked_at"] = nil
p.CheckedAt = nil
if err := entity.Db().Model(p).Updates(updates).Error; err != nil {
return false, err
}
if len(detailUpdates) > 0 {
if err := entity.Db().Model(details).Updates(detailUpdates).Error; err != nil {
return false, err
}
}
return true, nil
}
// shouldUpdateString reports whether the provided field requests an update or removal.
func shouldUpdateString(v String) bool {
return v.Action == ActionUpdate || v.Action == ActionRemove
}

View file

@ -0,0 +1,308 @@
package batch
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/search"
)
// TestSavePhotos covers SavePhotos scenarios.
func TestSavePhotos(t *testing.T) {
t.Run("UpdatesTitleAndFavorite", func(t *testing.T) {
fixture := entity.PhotoFixtures.Get("Photo01")
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, photo)
originalTitle := photo.PhotoTitle
originalTitleSrc := photo.TitleSrc
originalFavorite := photo.PhotoFavorite
originalChecked := photo.CheckedAt
originalEdited := photo.EditedAt
values := &PhotosForm{
PhotoTitle: String{Value: originalTitle + " (Batch)", Action: ActionUpdate},
PhotoFavorite: Bool{Value: !originalFavorite, Action: ActionUpdate},
}
req, err := NewPhotoSaveRequest(photo, values)
require.NoError(t, err)
results, err := SavePhotos([]*PhotoSaveRequest{req})
require.NoError(t, err)
require.Len(t, results, 1)
assert.True(t, results[0])
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, updated)
assert.Equal(t, values.PhotoTitle.Value, updated.PhotoTitle)
assert.Equal(t, entity.SrcBatch, updated.TitleSrc)
assert.Equal(t, values.PhotoFavorite.Value, updated.PhotoFavorite)
assert.Nil(t, updated.CheckedAt)
assert.NotNil(t, updated.EditedAt)
restorePhoto(t, fixture.PhotoUID, entity.Values{
"photo_title": originalTitle,
"title_src": originalTitleSrc,
"photo_favorite": originalFavorite,
"checked_at": originalChecked,
"edited_at": originalEdited,
})
})
t.Run("UpdatesDateFields", func(t *testing.T) {
fixture := entity.PhotoFixtures.Get("Photo02")
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, photo)
originalYear := photo.PhotoYear
originalMonth := photo.PhotoMonth
originalDay := photo.PhotoDay
originalTakenAt := photo.TakenAt
originalTakenAtLocal := photo.TakenAtLocal
originalTimeZone := photo.TimeZone
originalTakenSrc := photo.TakenSrc
originalChecked := photo.CheckedAt
originalEdited := photo.EditedAt
values := &PhotosForm{
PhotoYear: Int{Value: 2020, Action: ActionUpdate},
PhotoMonth: Int{Value: 5, Action: ActionUpdate},
PhotoDay: Int{Value: 15, Action: ActionUpdate},
}
req, err := NewPhotoSaveRequest(photo, values)
require.NoError(t, err)
results, err := SavePhotos([]*PhotoSaveRequest{req})
require.NoError(t, err)
require.Len(t, results, 1)
assert.True(t, results[0])
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, updated)
assert.Equal(t, 2020, updated.PhotoYear)
assert.Equal(t, 5, updated.PhotoMonth)
assert.Equal(t, 15, updated.PhotoDay)
assert.Equal(t, entity.SrcBatch, updated.TakenSrc)
assert.Nil(t, updated.CheckedAt)
restorePhoto(t, fixture.PhotoUID, entity.Values{
"photo_year": originalYear,
"photo_month": originalMonth,
"photo_day": originalDay,
"taken_at": originalTakenAt,
"taken_at_local": originalTakenAtLocal,
"time_zone": originalTimeZone,
"taken_src": originalTakenSrc,
"checked_at": originalChecked,
"edited_at": originalEdited,
})
})
t.Run("RemovesStrings", func(t *testing.T) {
fixture := entity.PhotoFixtures.Get("Photo03")
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, photo)
original := entity.Values{
"photo_title": photo.PhotoTitle,
"title_src": photo.TitleSrc,
"photo_caption": photo.PhotoCaption,
"caption_src": photo.CaptionSrc,
"details_subject": photo.GetDetails().Subject,
"details_subject_src": photo.GetDetails().SubjectSrc,
"details_artist": photo.GetDetails().Artist,
"details_artist_src": photo.GetDetails().ArtistSrc,
"details_copyright": photo.GetDetails().Copyright,
"details_copyright_src": photo.GetDetails().CopyrightSrc,
"details_license": photo.GetDetails().License,
"details_license_src": photo.GetDetails().LicenseSrc,
"checked_at": photo.CheckedAt,
"edited_at": photo.EditedAt,
}
setValues := &PhotosForm{
PhotoTitle: String{Value: "Batch Title", Action: ActionUpdate},
PhotoCaption: String{Value: "Batch Caption", Action: ActionUpdate},
DetailsSubject: String{Value: "Batch Subject", Action: ActionUpdate},
DetailsArtist: String{Value: "Batch Artist", Action: ActionUpdate},
DetailsCopyright: String{Value: "Batch Copyright", Action: ActionUpdate},
DetailsLicense: String{Value: "Batch License", Action: ActionUpdate},
}
setReq, err := NewPhotoSaveRequest(photo, setValues)
require.NoError(t, err)
_, err = SavePhotos([]*PhotoSaveRequest{setReq})
require.NoError(t, err)
removeValues := &PhotosForm{
PhotoTitle: String{Action: ActionRemove},
PhotoCaption: String{Action: ActionRemove},
DetailsSubject: String{Action: ActionRemove},
DetailsArtist: String{Action: ActionRemove},
DetailsCopyright: String{Action: ActionRemove},
DetailsLicense: String{Action: ActionRemove},
PhotoYear: Int{Value: -1, Action: ActionUpdate},
PhotoMonth: Int{Value: -1, Action: ActionUpdate},
PhotoDay: Int{Value: -1, Action: ActionUpdate},
PhotoAltitude: Int{Value: 0, Action: ActionUpdate},
}
removeReq, err := NewPhotoSaveRequest(photo, removeValues)
require.NoError(t, err)
results, err := SavePhotos([]*PhotoSaveRequest{removeReq})
require.NoError(t, err)
require.Len(t, results, 1)
assert.True(t, results[0])
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, updated)
assert.Equal(t, "", updated.PhotoTitle)
assert.Equal(t, "", updated.PhotoCaption)
assert.Equal(t, "", updated.GetDetails().Subject)
assert.Equal(t, "", updated.GetDetails().Artist)
assert.Equal(t, "", updated.GetDetails().Copyright)
assert.Equal(t, "", updated.GetDetails().License)
restorePhoto(t, fixture.PhotoUID, original)
})
}
// TestNewPhotoSaveRequest ensures the helper validates inputs before building requests.
func TestNewPhotoSaveRequest(t *testing.T) {
t.Run("NilValues", func(t *testing.T) {
fixture := entity.PhotoFixtures.Get("Photo01")
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, photo)
req, err := NewPhotoSaveRequest(photo, nil)
assert.Nil(t, req)
assert.Error(t, err)
})
t.Run("BuildsRequest", func(t *testing.T) {
fixture := entity.PhotoFixtures.Get("Photo02")
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, photo)
values := &PhotosForm{
PhotoTitle: String{Value: "Helper", Action: ActionUpdate},
}
req, err := NewPhotoSaveRequest(photo, values)
require.NoError(t, err)
require.NotNil(t, req)
assert.Equal(t, photo, req.Photo)
assert.Equal(t, values, req.Values)
assert.NotNil(t, req.Form)
assert.Equal(t, "Helper", req.Form.PhotoTitle)
})
}
// TestPreparePhotoSaveRequests verifies helper behavior.
func TestPreparePhotoSaveRequests(t *testing.T) {
t.Run("NilValues", func(t *testing.T) {
preloaded := map[string]*entity.Photo{}
requests, updated := PreparePhotoSaveRequests(nil, preloaded, nil)
assert.Nil(t, requests)
assert.Equal(t, preloaded, updated)
})
t.Run("LoadsMissingPhoto", func(t *testing.T) {
fixture := entity.PhotoFixtures.Get("Photo01")
values := &PhotosForm{PhotoTitle: String{Value: "Prepared", Action: ActionUpdate}}
photos := search.PhotoResults{{PhotoUID: fixture.PhotoUID}}
requests, updated := PreparePhotoSaveRequests(photos, nil, values)
require.Len(t, requests, 1)
assert.NotNil(t, requests[0].Photo)
assert.Equal(t, "Prepared", requests[0].Form.PhotoTitle)
assert.Contains(t, updated, fixture.PhotoUID)
})
t.Run("SkipsMissing", func(t *testing.T) {
values := &PhotosForm{PhotoTitle: String{Value: "Prepared", Action: ActionUpdate}}
photos := search.PhotoResults{{PhotoUID: "pt_does_not_exist"}}
requests, updated := PreparePhotoSaveRequests(photos, nil, values)
assert.Len(t, requests, 0)
assert.Empty(t, updated)
})
}
// TestPrepareAndSavePhotos verifies the full helper workflow.
func TestPrepareAndSavePhotos(t *testing.T) {
t.Run("NilValues", func(t *testing.T) {
result, err := PrepareAndSavePhotos(nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, result)
assert.NotNil(t, result.Preloaded)
assert.Len(t, result.Requests, 0)
assert.Len(t, result.Results, 0)
})
t.Run("PersistsChanges", func(t *testing.T) {
fixture := entity.PhotoFixtures.Get("Photo02")
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, photo)
originalFavorite := photo.PhotoFavorite
values := &PhotosForm{
PhotoFavorite: Bool{Value: !originalFavorite, Action: ActionUpdate},
}
photos := search.PhotoResults{{PhotoUID: fixture.PhotoUID}}
result, err := PrepareAndSavePhotos(photos, nil, values)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, result.Requests, 1)
require.Len(t, result.Results, 1)
assert.True(t, result.Results[0])
assert.True(t, result.SavedAny)
assert.Equal(t, 1, result.UpdatedCount)
assert.Contains(t, result.Preloaded, fixture.PhotoUID)
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, updated)
assert.Equal(t, !originalFavorite, updated.PhotoFavorite)
restorePhoto(t, fixture.PhotoUID, entity.Values{
"photo_favorite": originalFavorite,
})
})
}
// restorePhoto rewinds DB state for the provided fixture so tests stay isolated.
func restorePhoto(t *testing.T, photoUID string, values entity.Values) {
t.Helper()
if values == nil {
return
}
detailUpdates := entity.Values{}
detailKeys := []string{
"details_subject", "details_subject_src",
"details_artist", "details_artist_src",
"details_copyright", "details_copyright_src",
"details_license", "details_license_src",
}
for _, k := range detailKeys {
if v, ok := values[k]; ok {
detailUpdates[strings.TrimPrefix(k, "details_")] = v
delete(values, k)
}
}
if err := entity.Db().Model(&entity.Photo{}).Where("photo_uid = ?", photoUID).Updates(values).Error; err != nil {
t.Fatalf("failed to restore photo %s: %v", photoUID, err)
}
if len(detailUpdates) > 0 {
if photo := entity.FindPhoto(entity.Photo{PhotoUID: photoUID}); photo != nil {
if err := entity.Db().Model(photo.GetDetails()).Updates(detailUpdates).Error; err != nil {
t.Fatalf("failed to restore photo details %s: %v", photoUID, err)
}
}
}
}