Batch: Update YAML file backups for all referenced albums #271 #5324

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-11-20 17:36:33 +01:00
parent 117c8db73b
commit 2e85caa6b0
5 changed files with 236 additions and 4 deletions

View File

@@ -1,6 +1,6 @@
## PhotoPrism — Batch Edit Package ## PhotoPrism — Batch Edit Package
**Last Updated:** November 19, 2025 **Last Updated:** November 20, 2025
### Overview ### Overview
@@ -35,8 +35,9 @@ The `internal/photoprism/batch` package implements the form schema (`PhotosForm`
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. 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. 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, `MutationStats`) to the API layer. 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, `MutationStats`) 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. 6. `resolveBatchItemValues` runs before per-photo work so album/label additions referenced by title are looked up or created once per batch (rather than per photo) and deleted albums/labels are restored before use.
7. Refreshed models and values are sent back in the response form so the frontend can merge and display the changes, and the mutation stats drive the production log line (`updated photo metadata (1/3) and labels (3/3)`) so operators can see which parts of the request succeeded even when metadata columns remained untouched. 7. `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. When album mutations occurred and YAML backups are enabled, the resolved album list is written back to disk via `updateAlbumBackups` after all database work succeeds.
8. Refreshed models and values are sent back in the response form so the frontend can merge and display the changes, and the mutation stats drive the production log line (`updated photo metadata (1/3) and labels (3/3)`) so operators can see which parts of the request succeeded even when metadata columns remained untouched.
### Batch Edit API Endpoint ### Batch Edit API Endpoint
@@ -154,7 +155,7 @@ Each field embeds one of the typed wrappers (`String`, `Bool`, `Time`, `Int`, et
- `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. - `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.). - 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. - 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. `Items.ResolveValuesByTitle` plus `resolveBatchItemValues` ensure those creations happen once per batch, so per-photo calls operate on cached UIDs instead of repeating lookups.
- 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. - 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`. - Keyword keywords stay consistent because label removals call `photo.RemoveKeyword` and `SaveDetails` immediately, while location edits append unique place keywords via `txt.UniqueWords`.
@@ -220,6 +221,7 @@ Testers reported intermittent `Error 1213 (40001)` deadlocks when multiple batch
- `internal/photoprism/batch/datelogic_test.go` ensures cross-field dependencies (local time vs. UTC) stay consistent. - `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/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.). - `internal/api/batch_photos_edit_test.go` provides end-to-end coverage for response envelopes (`SuccessNoChange`, `SuccessRemoveValues`, etc.).
- `internal/photoprism/batch/save_resolve_test.go` validates pre-resolution helpers for albums/labels, while `save_backup_test.go` covers the YAML backup flow controlled by `updateAlbumBackups`.
- **Logging** - **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. The final `INFO` line now reports metadata success counts alongside album and label mutations (including error tallies) so label-only edits no longer read as 0 out of N photos”. - 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. The final `INFO` line now reports metadata success counts alongside album and label mutations (including error tallies) so label-only edits no longer read as 0 out of N photos”.
- **Metrics & Alerts** - **Metrics & Alerts**
@@ -240,6 +242,9 @@ Testers reported intermittent `Error 1213 (40001)` deadlocks when multiple batch
- `convert.go` — translates `PhotosForm` into `form.Photo` instances for persistence. - `convert.go` — translates `PhotosForm` into `form.Photo` instances for persistence.
- `apply_albums.go` / `apply_labels.go` — album and label mutation helpers shared across API endpoints. - `apply_albums.go` / `apply_labels.go` — album and label mutation helpers shared across API endpoints.
- `save.go` — differential persistence, `PreparePhotoSaveRequests`, `PrepareAndSavePhotos`, `NewPhotoSaveRequest`, `PhotoSaveRequest`, background worker triggers. - `save.go` — differential persistence, `PreparePhotoSaveRequests`, `PrepareAndSavePhotos`, `NewPhotoSaveRequest`, `PhotoSaveRequest`, background worker triggers.
- `save_photo.go``savePhoto` applies a single request, compares old/new values, and writes only the changed columns (indirectly invoked by `SavePhotos`).
- `save_resolve.go` — album/label title resolution helpers that run before persistence so per-photo work only receives resolved UIDs.
- `save_backup.go` — YAML backup synchronisation for albums whenever batch edits touch them and backups are enabled.
- `datelogic.go` — helpers for reconciling time zones and date parts when the UI only supplies partial values. - `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). - `values.go` — typed wrappers for request fields (value + action + mixed flag).

View File

@@ -195,6 +195,11 @@ func PrepareAndSavePhotos(photos search.PhotoResults, preloaded map[string]*enti
log.Infof("batch: no photos have been updated [%s]", time.Since(start)) log.Infof("batch: no photos have been updated [%s]", time.Since(start))
} }
// Update YAML backups for all albums referenced in the current batch request.
if result.Stats.AlbumMutations > 0 {
updateAlbumBackups(values)
}
return result, nil return result, nil
} }

View File

@@ -0,0 +1,74 @@
package batch
import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
// updateAlbumBackups writes YAML snapshots for all albums referenced in the current batch request
// so the on-disk backups stay in sync with newly added or removed photos.
func updateAlbumBackups(values *PhotosForm) {
if values == nil || values.Albums.Action != ActionUpdate {
return
}
conf := get.Config()
if conf == nil || !conf.BackupAlbums() {
return
}
backupPath := conf.BackupAlbumsPath()
if backupPath == "" {
return
}
rawUIDs := values.Albums.GetValuesByActions([]Action{ActionAdd, ActionRemove})
if len(rawUIDs) == 0 {
return
}
validUIDs := make([]string, 0, len(rawUIDs))
for _, uid := range rawUIDs {
if rnd.InvalidUID(uid, entity.AlbumUID) {
log.Debugf("batch: invalid album uid %s (skip yaml)", clean.Log(uid))
continue
}
validUIDs = append(validUIDs, uid)
}
if len(validUIDs) == 0 {
return
}
albums, err := query.AlbumsByUID(validUIDs, true)
if err != nil {
log.Warnf("batch: failed to load albums for yaml backup: %s", err)
return
}
for i := range albums {
album := &albums[i]
if album == nil {
log.Debugf("batch: album is nil (update yaml)")
continue
}
if !album.HasID() {
log.Debugf("batch: album has no ID (update yaml)")
continue
}
if err = album.SaveBackupYaml(backupPath); err != nil {
log.Warnf("batch: failed to save album backup %s: %s", clean.Log(album.AlbumUID), err)
}
}
}

View File

@@ -0,0 +1,64 @@
package batch
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
func TestUpdateAlbumBackups(t *testing.T) {
conf := get.Config()
require.NotNil(t, conf)
album := entity.AlbumFixtures.Get("christmas2030")
require.True(t, album.HasID())
t.Run("WritesFile", func(t *testing.T) {
original := conf.BackupAlbums()
conf.Options().BackupAlbums = true
t.Cleanup(func() { conf.Options().BackupAlbums = original })
backupFile, _, err := album.YamlFileName(conf.BackupAlbumsPath())
require.NoError(t, err)
_ = os.Remove(backupFile)
values := &PhotosForm{
Albums: Items{
Action: ActionUpdate,
Items: []Item{
{Value: album.AlbumUID, Action: ActionAdd},
{Value: album.AlbumUID, Action: ActionAdd},
{Value: "invalid", Action: ActionAdd},
},
},
}
updateAlbumBackups(values)
require.FileExists(t, backupFile)
t.Cleanup(func() { _ = os.Remove(backupFile) })
})
t.Run("SkipsWhenDisabled", func(t *testing.T) {
original := conf.BackupAlbums()
conf.Options().BackupAlbums = false
t.Cleanup(func() { conf.Options().BackupAlbums = original })
backupFile := filepath.Join(conf.BackupAlbumsPath(), album.AlbumType, album.AlbumUID+".yml")
_ = os.Remove(backupFile)
values := &PhotosForm{
Albums: Items{
Action: ActionUpdate,
Items: []Item{{Value: album.AlbumUID, Action: ActionAdd}},
},
}
updateAlbumBackups(values)
_, err := os.Stat(backupFile)
require.True(t, os.IsNotExist(err))
})
}

View File

@@ -0,0 +1,84 @@
package batch
import (
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
)
func TestSavePhoto(t *testing.T) {
fixture := entity.PhotoFixtures.Get("Photo01")
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, photo)
originalTitle := photo.PhotoTitle
originalFavorite := photo.PhotoFavorite
originalYear := photo.PhotoYear
originalMonth := photo.PhotoMonth
originalDay := photo.PhotoDay
originalChecked := photo.CheckedAt
originalEdited := photo.EditedAt
t.Run("InvalidRequest", func(t *testing.T) {
_, err := savePhoto(nil)
require.Error(t, err)
})
t.Run("UpdatesCoreFields", func(t *testing.T) {
values := &PhotosForm{
PhotoTitle: String{Value: fmt.Sprintf("Batch %d", time.Now().UnixNano()), Action: ActionUpdate},
PhotoFavorite: Bool{Value: !photo.PhotoFavorite, Action: ActionUpdate},
PhotoYear: Int{Value: 2024, Action: ActionUpdate},
PhotoMonth: Int{Value: 12, Action: ActionUpdate},
PhotoDay: Int{Value: 31, Action: ActionUpdate},
}
frm := &form.Photo{
PhotoTitle: values.PhotoTitle.Value,
PhotoFavorite: values.PhotoFavorite.Value,
PhotoYear: values.PhotoYear.Value,
PhotoMonth: values.PhotoMonth.Value,
PhotoDay: values.PhotoDay.Value,
TimeZone: photo.TimeZone,
TakenAtLocal: photo.TakenAtLocal,
TakenSrc: entity.SrcBatch,
}
req, err := NewPhotoSaveRequest(photo, values)
require.NoError(t, err)
req.Form = frm
saved, err := savePhoto(req)
require.NoError(t, err)
require.True(t, saved)
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
require.NotNil(t, updated)
require.Equal(t, values.PhotoTitle.Value, updated.PhotoTitle)
require.Equal(t, values.PhotoFavorite.Value, updated.PhotoFavorite)
require.Equal(t, values.PhotoYear.Value, updated.PhotoYear)
require.Equal(t, values.PhotoMonth.Value, updated.PhotoMonth)
require.Equal(t, values.PhotoDay.Value, updated.PhotoDay)
require.Nil(t, updated.CheckedAt)
require.NotNil(t, updated.EditedAt)
restorePhoto(t, fixture.PhotoUID, entity.Values{
"photo_title": originalTitle,
"photo_favorite": originalFavorite,
"photo_year": originalYear,
"photo_month": originalMonth,
"photo_day": originalDay,
"checked_at": originalChecked,
"edited_at": originalEdited,
})
})
t.Run("NoChanges", func(t *testing.T) {
req, err := NewPhotoSaveRequest(photo, &PhotosForm{})
require.NoError(t, err)
saved, err := savePhoto(req)
require.NoError(t, err)
require.False(t, saved)
})
}