Skip to content

Commit 2e85caa

Browse files
committed
Batch: Update YAML file backups for all referenced albums #271 photoprism#5324
Signed-off-by: Michael Mayer <michael@photoprism.app>
1 parent 117c8db commit 2e85caa

File tree

5 files changed

+236
-4
lines changed

5 files changed

+236
-4
lines changed

internal/photoprism/batch/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## PhotoPrism — Batch Edit Package
22

3-
**Last Updated:** November 19, 2025
3+
**Last Updated:** November 20, 2025
44

55
### Overview
66

@@ -35,8 +35,9 @@ The `internal/photoprism/batch` package implements the form schema (`PhotosForm`
3535
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.
3636
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.
3737
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.
38-
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.
39-
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.
38+
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.
39+
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.
40+
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.
4041

4142
### Batch Edit API Endpoint
4243

@@ -154,7 +155,7 @@ Each field embeds one of the typed wrappers (`String`, `Bool`, `Time`, `Int`, et
154155

155156
- `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.
156157
- 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.).
157-
- 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.
158+
- 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.
158159
- 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.
159160
- Keyword keywords stay consistent because label removals call `photo.RemoveKeyword` and `SaveDetails` immediately, while location edits append unique place keywords via `txt.UniqueWords`.
160161

@@ -220,6 +221,7 @@ Testers reported intermittent `Error 1213 (40001)` deadlocks when multiple batch
220221
- `internal/photoprism/batch/datelogic_test.go` ensures cross-field dependencies (local time vs. UTC) stay consistent.
221222
- `internal/photoprism/batch/save_test.go` exercises partial updates, detail edits, `CheckedAt` resets, and the `PreparePhotoSaveRequests` / `PrepareAndSavePhotos` helpers.
222223
- `internal/api/batch_photos_edit_test.go` provides end-to-end coverage for response envelopes (`SuccessNoChange`, `SuccessRemoveValues`, etc.).
224+
- `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`.
223225
- **Logging**
224226
- 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”.
225227
- **Metrics & Alerts**
@@ -240,6 +242,9 @@ Testers reported intermittent `Error 1213 (40001)` deadlocks when multiple batch
240242
- `convert.go` — translates `PhotosForm` into `form.Photo` instances for persistence.
241243
- `apply_albums.go` / `apply_labels.go` — album and label mutation helpers shared across API endpoints.
242244
- `save.go` — differential persistence, `PreparePhotoSaveRequests`, `PrepareAndSavePhotos`, `NewPhotoSaveRequest`, `PhotoSaveRequest`, background worker triggers.
245+
- `save_photo.go``savePhoto` applies a single request, compares old/new values, and writes only the changed columns (indirectly invoked by `SavePhotos`).
246+
- `save_resolve.go` — album/label title resolution helpers that run before persistence so per-photo work only receives resolved UIDs.
247+
- `save_backup.go` — YAML backup synchronisation for albums whenever batch edits touch them and backups are enabled.
243248
- `datelogic.go` — helpers for reconciling time zones and date parts when the UI only supplies partial values.
244249
- `values.go` — typed wrappers for request fields (value + action + mixed flag).
245250

internal/photoprism/batch/save.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ func PrepareAndSavePhotos(photos search.PhotoResults, preloaded map[string]*enti
195195
log.Infof("batch: no photos have been updated [%s]", time.Since(start))
196196
}
197197

198+
// Update YAML backups for all albums referenced in the current batch request.
199+
if result.Stats.AlbumMutations > 0 {
200+
updateAlbumBackups(values)
201+
}
202+
198203
return result, nil
199204
}
200205

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package batch
2+
3+
import (
4+
"github.com/photoprism/photoprism/internal/entity"
5+
"github.com/photoprism/photoprism/internal/entity/query"
6+
"github.com/photoprism/photoprism/internal/photoprism/get"
7+
"github.com/photoprism/photoprism/pkg/clean"
8+
"github.com/photoprism/photoprism/pkg/rnd"
9+
)
10+
11+
// updateAlbumBackups writes YAML snapshots for all albums referenced in the current batch request
12+
// so the on-disk backups stay in sync with newly added or removed photos.
13+
func updateAlbumBackups(values *PhotosForm) {
14+
if values == nil || values.Albums.Action != ActionUpdate {
15+
return
16+
}
17+
18+
conf := get.Config()
19+
20+
if conf == nil || !conf.BackupAlbums() {
21+
return
22+
}
23+
24+
backupPath := conf.BackupAlbumsPath()
25+
26+
if backupPath == "" {
27+
return
28+
}
29+
30+
rawUIDs := values.Albums.GetValuesByActions([]Action{ActionAdd, ActionRemove})
31+
32+
if len(rawUIDs) == 0 {
33+
return
34+
}
35+
36+
validUIDs := make([]string, 0, len(rawUIDs))
37+
38+
for _, uid := range rawUIDs {
39+
if rnd.InvalidUID(uid, entity.AlbumUID) {
40+
log.Debugf("batch: invalid album uid %s (skip yaml)", clean.Log(uid))
41+
continue
42+
}
43+
validUIDs = append(validUIDs, uid)
44+
}
45+
46+
if len(validUIDs) == 0 {
47+
return
48+
}
49+
50+
albums, err := query.AlbumsByUID(validUIDs, true)
51+
52+
if err != nil {
53+
log.Warnf("batch: failed to load albums for yaml backup: %s", err)
54+
return
55+
}
56+
57+
for i := range albums {
58+
album := &albums[i]
59+
60+
if album == nil {
61+
log.Debugf("batch: album is nil (update yaml)")
62+
continue
63+
}
64+
65+
if !album.HasID() {
66+
log.Debugf("batch: album has no ID (update yaml)")
67+
continue
68+
}
69+
70+
if err = album.SaveBackupYaml(backupPath); err != nil {
71+
log.Warnf("batch: failed to save album backup %s: %s", clean.Log(album.AlbumUID), err)
72+
}
73+
}
74+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package batch
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/photoprism/photoprism/internal/entity"
11+
"github.com/photoprism/photoprism/internal/photoprism/get"
12+
)
13+
14+
func TestUpdateAlbumBackups(t *testing.T) {
15+
conf := get.Config()
16+
require.NotNil(t, conf)
17+
album := entity.AlbumFixtures.Get("christmas2030")
18+
require.True(t, album.HasID())
19+
20+
t.Run("WritesFile", func(t *testing.T) {
21+
original := conf.BackupAlbums()
22+
conf.Options().BackupAlbums = true
23+
t.Cleanup(func() { conf.Options().BackupAlbums = original })
24+
25+
backupFile, _, err := album.YamlFileName(conf.BackupAlbumsPath())
26+
require.NoError(t, err)
27+
_ = os.Remove(backupFile)
28+
29+
values := &PhotosForm{
30+
Albums: Items{
31+
Action: ActionUpdate,
32+
Items: []Item{
33+
{Value: album.AlbumUID, Action: ActionAdd},
34+
{Value: album.AlbumUID, Action: ActionAdd},
35+
{Value: "invalid", Action: ActionAdd},
36+
},
37+
},
38+
}
39+
40+
updateAlbumBackups(values)
41+
require.FileExists(t, backupFile)
42+
43+
t.Cleanup(func() { _ = os.Remove(backupFile) })
44+
})
45+
t.Run("SkipsWhenDisabled", func(t *testing.T) {
46+
original := conf.BackupAlbums()
47+
conf.Options().BackupAlbums = false
48+
t.Cleanup(func() { conf.Options().BackupAlbums = original })
49+
50+
backupFile := filepath.Join(conf.BackupAlbumsPath(), album.AlbumType, album.AlbumUID+".yml")
51+
_ = os.Remove(backupFile)
52+
53+
values := &PhotosForm{
54+
Albums: Items{
55+
Action: ActionUpdate,
56+
Items: []Item{{Value: album.AlbumUID, Action: ActionAdd}},
57+
},
58+
}
59+
60+
updateAlbumBackups(values)
61+
_, err := os.Stat(backupFile)
62+
require.True(t, os.IsNotExist(err))
63+
})
64+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package batch
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/photoprism/photoprism/internal/entity"
11+
"github.com/photoprism/photoprism/internal/form"
12+
)
13+
14+
func TestSavePhoto(t *testing.T) {
15+
fixture := entity.PhotoFixtures.Get("Photo01")
16+
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
17+
require.NotNil(t, photo)
18+
originalTitle := photo.PhotoTitle
19+
originalFavorite := photo.PhotoFavorite
20+
originalYear := photo.PhotoYear
21+
originalMonth := photo.PhotoMonth
22+
originalDay := photo.PhotoDay
23+
originalChecked := photo.CheckedAt
24+
originalEdited := photo.EditedAt
25+
26+
t.Run("InvalidRequest", func(t *testing.T) {
27+
_, err := savePhoto(nil)
28+
require.Error(t, err)
29+
})
30+
t.Run("UpdatesCoreFields", func(t *testing.T) {
31+
values := &PhotosForm{
32+
PhotoTitle: String{Value: fmt.Sprintf("Batch %d", time.Now().UnixNano()), Action: ActionUpdate},
33+
PhotoFavorite: Bool{Value: !photo.PhotoFavorite, Action: ActionUpdate},
34+
PhotoYear: Int{Value: 2024, Action: ActionUpdate},
35+
PhotoMonth: Int{Value: 12, Action: ActionUpdate},
36+
PhotoDay: Int{Value: 31, Action: ActionUpdate},
37+
}
38+
frm := &form.Photo{
39+
PhotoTitle: values.PhotoTitle.Value,
40+
PhotoFavorite: values.PhotoFavorite.Value,
41+
PhotoYear: values.PhotoYear.Value,
42+
PhotoMonth: values.PhotoMonth.Value,
43+
PhotoDay: values.PhotoDay.Value,
44+
TimeZone: photo.TimeZone,
45+
TakenAtLocal: photo.TakenAtLocal,
46+
TakenSrc: entity.SrcBatch,
47+
}
48+
49+
req, err := NewPhotoSaveRequest(photo, values)
50+
require.NoError(t, err)
51+
req.Form = frm
52+
53+
saved, err := savePhoto(req)
54+
require.NoError(t, err)
55+
require.True(t, saved)
56+
57+
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
58+
require.NotNil(t, updated)
59+
require.Equal(t, values.PhotoTitle.Value, updated.PhotoTitle)
60+
require.Equal(t, values.PhotoFavorite.Value, updated.PhotoFavorite)
61+
require.Equal(t, values.PhotoYear.Value, updated.PhotoYear)
62+
require.Equal(t, values.PhotoMonth.Value, updated.PhotoMonth)
63+
require.Equal(t, values.PhotoDay.Value, updated.PhotoDay)
64+
require.Nil(t, updated.CheckedAt)
65+
require.NotNil(t, updated.EditedAt)
66+
67+
restorePhoto(t, fixture.PhotoUID, entity.Values{
68+
"photo_title": originalTitle,
69+
"photo_favorite": originalFavorite,
70+
"photo_year": originalYear,
71+
"photo_month": originalMonth,
72+
"photo_day": originalDay,
73+
"checked_at": originalChecked,
74+
"edited_at": originalEdited,
75+
})
76+
})
77+
t.Run("NoChanges", func(t *testing.T) {
78+
req, err := NewPhotoSaveRequest(photo, &PhotosForm{})
79+
require.NoError(t, err)
80+
saved, err := savePhoto(req)
81+
require.NoError(t, err)
82+
require.False(t, saved)
83+
})
84+
}

0 commit comments

Comments
 (0)