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 }