Files
photoprism/internal/api/batch_photos_edit.go
2025-11-27 17:54:05 +01:00

178 lines
4.9 KiB
Go

package api
import (
"errors"
"net/http"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"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/event"
"github.com/photoprism/photoprism/internal/photoprism/batch"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
)
// BatchPhotosEdit returns and updates the metadata of multiple photos.
//
// @Summary returns and updates the metadata of multiple photos
// @Id BatchPhotosEdit
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} batch.PhotosResponse
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param Request body batch.PhotosRequest true "photos selection and values"
// @Router /api/v1/batch/photos/edit [post]
func BatchPhotosEdit(router *gin.RouterGroup) {
router.Match(MethodsPutPost, "/batch/photos/edit", func(c *gin.Context) {
// 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
}
// Check feature flags.
if !get.Config().Settings().Features.BatchEdit {
AbortFeatureDisabled(c)
return
}
var frm batch.PhotosRequest
// Assign and validate request form values.
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c, err)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
// Fetch selected photos from database.
photos, count, err := search.BatchPhotos(frm.Photos, s)
log.Debugf("batch: %s selected for editing", english.Plural(count, "photo", "photos"))
// Abort if no photos were found.
if err != nil {
log.Errorf("batch: %s (load selection)", clean.Error(err))
AbortUnexpectedError(c)
return
}
var preloadedPhotos map[string]*entity.Photo
if hydrated, preloadErr := query.PhotoPreloadByUIDs(photos.UIDs()); preloadErr != nil {
log.Errorf("batch: %s (preload selection)", preloadErr)
AbortUnexpectedError(c)
return
} else {
preloadedPhotos = mapPhotosByUID(hydrated)
}
var (
saveRequests []*batch.PhotoSaveRequest
saveResults []bool
savedAny bool
)
if frm.Values != nil {
outcome, saveErr := batch.PrepareAndSavePhotos(photos, preloadedPhotos, frm.Values)
switch {
case errors.Is(saveErr, batch.ErrBatchEditBusy), errors.Is(saveErr, batch.ErrBatchEditCanceled):
log.Warnf("batch: %s (save)", saveErr)
AbortBusy(c)
return
case saveErr != nil:
log.Errorf("batch: %s (save)", saveErr)
event.AuditErr([]string{ClientIP(c), "session %s", "batch edit", status.Error(saveErr)}, s.RefID)
AbortUnexpectedError(c)
return
}
saveRequests = outcome.Requests
saveResults = outcome.Results
preloadedPhotos = outcome.Preloaded
savedAny = outcome.SavedAny
if n := len(saveRequests); n > 0 && (savedAny || outcome.Stats.AlbumMutations > 0 || outcome.Stats.LabelMutations > 0) {
event.AuditInfo([]string{ClientIP(c), "session %s", "batch edit", "update %s", status.Succeeded},
s.RefID, english.Plural(n, "picture", "pictures"))
}
}
// Refresh selection if core metadata changed; albums and labels are automatically refreshed.
if savedAny {
if photos, _, 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, preloadedPhotos)
if len(saveResults) > 0 {
for i, saved := range saveResults {
if !saved {
continue
}
photo := preloadedPhotos[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{
Models: photos,
Values: batchFrm,
}
c.JSON(http.StatusOK, data)
})
}
// 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 result
}