Skip to content

Commit f825253

Browse files
committed
Request exif and people metadata for sidecars
The sidecar is the server's asset response verbatim, and exifInfo/people are optional fields on that response: Immich omits exifInfo entirely and leaves people empty unless the search asks for them. We never asked, so every sidecar we have ever written is missing capture date, camera make and model, GPS coordinates, rating and faces. Set withExif and withPeople on the metadata search. Assets already on disk are skipped before the sidecar write, so this fix alone would only ever reach newly downloaded files and existing archives would keep their thin sidecars forever. Add --refresh-sidecars, which rewrites the sidecar of an asset already present without re-downloading the original. Album membership, also reported in #9, is not addressed here: Immich's asset response has no album field in any form, so no flag can supply it. Split out to #11. Refs #9
1 parent f4ce820 commit f825253

6 files changed

Lines changed: 187 additions & 8 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ immich-archiver --url https://photos.example.com --api-key your-user-api-key --d
1919
On a second run, assets already present on disk (verified by filename + a matching asset ID in
2020
the sidecar) are skipped, so re-running is cheap.
2121

22+
Because skipped assets never reach the write path, their sidecars are left untouched — an
23+
archive keeps whatever sidecars it was originally built with. To rewrite them against the
24+
current server metadata without re-downloading a single original, run with
25+
`--refresh-sidecars`.
26+
2227
### Layout
2328

2429
By default, assets land in `<dir>/{year}/{year}-{month}/`, e.g.:
@@ -47,6 +52,7 @@ Live Photos are downloaded as a still + a paired motion video sharing the same b
4752
| `--concurrency` | `4` | parallel downloads |
4853
| `--retries` | `3` | retry attempts on network/server errors |
4954
| `--dry-run` | `false` | preview without writing |
55+
| `--refresh-sidecars` | `false` | rewrite sidecars of assets already on disk (originals are not re-downloaded) |
5056
| `--verbose` / `-v` | `false` | log one line per asset instead of a progress summary |
5157

5258
## Development

cmd/progress.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ type progress struct {
1515
out io.Writer
1616
verbose bool
1717
dryRun bool
18+
refresh bool // report the refreshed count; suppressed when nothing can refresh
1819
mu sync.Mutex
1920

2021
downloaded int64
2122
skipped int64
23+
refreshed int64
2224
failed int64
2325
}
2426

25-
func newProgress(out io.Writer, verbose, dryRun bool) *progress {
26-
return &progress{out: out, verbose: verbose, dryRun: dryRun}
27+
func newProgress(out io.Writer, verbose, dryRun, refresh bool) *progress {
28+
return &progress{out: out, verbose: verbose, dryRun: dryRun, refresh: refresh}
2729
}
2830

2931
func (p *progress) report(e archive.Event) {
@@ -32,6 +34,8 @@ func (p *progress) report(e archive.Event) {
3234
atomic.AddInt64(&p.downloaded, 1)
3335
case archive.ActionSkipped:
3436
atomic.AddInt64(&p.skipped, 1)
37+
case archive.ActionRefreshed:
38+
atomic.AddInt64(&p.refreshed, 1)
3539
case archive.ActionFailed:
3640
atomic.AddInt64(&p.failed, 1)
3741
}
@@ -49,14 +53,30 @@ func (p *progress) report(e archive.Event) {
4953
_, _ = fmt.Fprintf(p.out, "%s %s\n", verb, e.Filename)
5054
case archive.ActionSkipped:
5155
_, _ = fmt.Fprintf(p.out, "skipped (already present) %s\n", e.Filename)
56+
case archive.ActionRefreshed:
57+
refreshVerb := "refreshed sidecar"
58+
if p.dryRun {
59+
refreshVerb = "would refresh sidecar"
60+
}
61+
_, _ = fmt.Fprintf(p.out, "%s %s\n", refreshVerb, e.Filename)
5262
case archive.ActionFailed:
5363
_, _ = fmt.Fprintf(p.out, "FAILED %s: %v\n", e.Filename, e.Err)
5464
}
5565
return
5666
}
5767

58-
_, _ = fmt.Fprintf(p.out, "\rdownloaded %d, skipped %d, failed %d",
59-
atomic.LoadInt64(&p.downloaded), atomic.LoadInt64(&p.skipped), atomic.LoadInt64(&p.failed))
68+
_, _ = fmt.Fprintf(p.out, "\rdownloaded %d, skipped %d%s, failed %d",
69+
atomic.LoadInt64(&p.downloaded), atomic.LoadInt64(&p.skipped),
70+
p.refreshedSegment(atomic.LoadInt64(&p.refreshed)), atomic.LoadInt64(&p.failed))
71+
}
72+
73+
// refreshedSegment renders ", refreshed N" only when sidecar refreshing is
74+
// enabled, so the normal status line keeps its existing shape.
75+
func (p *progress) refreshedSegment(n int64) string {
76+
if !p.refresh {
77+
return ""
78+
}
79+
return fmt.Sprintf(", refreshed %d", n)
6080
}
6181

6282
func (p *progress) finish(stats archive.Stats) {
@@ -67,5 +87,6 @@ func (p *progress) finish(stats archive.Stats) {
6787
if p.dryRun {
6888
verb = "Would download"
6989
}
70-
_, _ = fmt.Fprintf(p.out, "%s: %d, skipped: %d, failed: %d\n", verb, stats.Downloaded, stats.Skipped, stats.Failed)
90+
_, _ = fmt.Fprintf(p.out, "%s: %d, skipped: %d%s, failed: %d\n",
91+
verb, stats.Downloaded, stats.Skipped, p.refreshedSegment(int64(stats.Refreshed)), stats.Failed)
7192
}

cmd/root.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type flags struct {
2626
concurrency int
2727
retries int
2828
dryRun bool
29+
refreshSidecars bool
2930
verbose bool
3031
}
3132

@@ -61,6 +62,7 @@ func newRootCmd() *cobra.Command {
6162
cmd.Flags().IntVar(&f.concurrency, "concurrency", 4, "number of assets to download in parallel")
6263
cmd.Flags().IntVar(&f.retries, "retries", 3, "number of retry attempts for failed downloads/requests")
6364
cmd.Flags().BoolVar(&f.dryRun, "dry-run", false, "list what would be downloaded without writing anything")
65+
cmd.Flags().BoolVar(&f.refreshSidecars, "refresh-sidecars", false, "rewrite the .json sidecar of assets already on disk (originals are not re-downloaded)")
6466
cmd.Flags().BoolVarP(&f.verbose, "verbose", "v", false, "log a line per asset instead of showing a progress bar")
6567

6668
return cmd
@@ -103,7 +105,7 @@ func runSync(cmd *cobra.Command, f *flags) error {
103105
sharedDir = f.dir + string(os.PathSeparator) + "shared-with-me"
104106
}
105107

106-
p := newProgress(cmd.OutOrStdout(), f.verbose, f.dryRun)
108+
p := newProgress(cmd.OutOrStdout(), f.verbose, f.dryRun, f.refreshSidecars)
107109
s := &archive.Syncer{
108110
Source: client,
109111
Options: archive.Options{
@@ -116,6 +118,7 @@ func runSync(cmd *cobra.Command, f *flags) error {
116118
Retries: f.retries,
117119
RetryDelay: 2 * time.Second,
118120
DryRun: f.dryRun,
121+
RefreshSidecars: f.refreshSidecars,
119122
},
120123
Reporter: p.report,
121124
}

internal/archive/sync.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ type Options struct {
3333
Retries int
3434
RetryDelay time.Duration
3535
DryRun bool
36+
37+
// RefreshSidecars rewrites the sidecar of an asset already on disk
38+
// instead of skipping it outright. Without this, an archive built by an
39+
// older version keeps its sidecars forever: assets present on disk never
40+
// reach the write path, so a metadata fix only ever reaches newly
41+
// downloaded files. Original files are still never re-downloaded.
42+
RefreshSidecars bool
3643
}
3744

3845
const unknownDateDir = "unknown-date"
@@ -45,6 +52,7 @@ const (
4552
ActionDownloaded Action = "downloaded"
4653
ActionSkipped Action = "skipped" // already present on disk
4754
ActionWouldFetch Action = "would-fetch" // dry-run
55+
ActionRefreshed Action = "refreshed" // file kept, sidecar rewritten
4856
ActionFailed Action = "failed"
4957
)
5058

@@ -65,6 +73,7 @@ type Reporter func(Event)
6573
type Stats struct {
6674
Downloaded int
6775
Skipped int
76+
Refreshed int
6877
Failed int
6978
}
7079

@@ -120,6 +129,8 @@ func (s *Syncer) Run(ctx context.Context) (Stats, error) {
120129
stats.Downloaded++
121130
case ActionSkipped:
122131
stats.Skipped++
132+
case ActionRefreshed:
133+
stats.Refreshed++
123134
case ActionFailed:
124135
stats.Failed++
125136
}
@@ -147,7 +158,12 @@ func (s *Syncer) Run(ctx context.Context) (Stats, error) {
147158
}()
148159
}
149160

150-
err := s.Source.SearchAssets(ctx, immich.SearchMetadataQuery{WithDeleted: false, IsArchived: nil}, func(a *immich.Asset) error {
161+
err := s.Source.SearchAssets(ctx, immich.SearchMetadataQuery{
162+
WithDeleted: false,
163+
IsArchived: nil,
164+
WithExif: true,
165+
WithPeople: true,
166+
}, func(a *immich.Asset) error {
151167
if a.IsTrashed {
152168
return nil
153169
}
@@ -238,7 +254,19 @@ func (s *Syncer) downloadOne(ctx context.Context, a *immich.Asset, dir, desiredN
238254
return
239255
}
240256
if exists {
241-
report(Event{AssetID: a.ID, Filename: filename, Action: ActionSkipped})
257+
if !s.Options.RefreshSidecars {
258+
report(Event{AssetID: a.ID, Filename: filename, Action: ActionSkipped})
259+
return
260+
}
261+
if s.Options.DryRun {
262+
report(Event{AssetID: a.ID, Filename: filename, Action: ActionRefreshed})
263+
return
264+
}
265+
if err := WriteSidecar(dir, filename, a.RawJSON); err != nil {
266+
report(Event{AssetID: a.ID, Filename: filename, Action: ActionFailed, Err: err})
267+
return
268+
}
269+
report(Event{AssetID: a.ID, Filename: filename, Action: ActionRefreshed})
242270
return
243271
}
244272
if s.Options.DryRun {

internal/archive/sync_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type fakeSource struct {
2121
files map[string]string // asset id -> file content
2222
albums []immich.Album
2323
albumAsts map[string][]*immich.Asset
24+
25+
lastQuery immich.SearchMetadataQuery
2426
}
2527

2628
func newFakeSource() *fakeSource {
@@ -59,6 +61,7 @@ func (f *fakeSource) add(a *immich.Asset, content string) {
5961
}
6062

6163
func (f *fakeSource) SearchAssets(ctx context.Context, query immich.SearchMetadataQuery, fn func(*immich.Asset) error) error {
64+
f.lastQuery = query
6265
for _, a := range f.assets {
6366
if err := fn(a); err != nil {
6467
return err
@@ -138,6 +141,117 @@ func TestSyncerDownloadsAndWritesSidecar(t *testing.T) {
138141
}
139142
}
140143

144+
// Immich omits exifInfo and leaves people empty unless the search asks for
145+
// them, and the sidecar is that response verbatim — so forgetting these flags
146+
// silently strips capture date, camera, GPS, rating and faces from every
147+
// sidecar written.
148+
func TestSyncerRequestsExifAndPeople(t *testing.T) {
149+
dir := t.TempDir()
150+
src := newFakeSource()
151+
src.add(mustAsset(t, "a1", "IMG_0001.jpg", "2005-06-15T10:00:00.000Z", nil), "photo-bytes")
152+
153+
s := &Syncer{Source: src, Options: baseOptions(dir)}
154+
if _, err := s.Run(context.Background()); err != nil {
155+
t.Fatalf("Run: %v", err)
156+
}
157+
if !src.lastQuery.WithExif {
158+
t.Error("search query did not set withExif")
159+
}
160+
if !src.lastQuery.WithPeople {
161+
t.Error("search query did not set withPeople")
162+
}
163+
}
164+
165+
func TestSyncerRefreshSidecarsRewritesExistingSidecar(t *testing.T) {
166+
dir := t.TempDir()
167+
src := newFakeSource()
168+
src.add(mustAsset(t, "a1", "IMG_0001.jpg", "2005-06-15T10:00:00.000Z", map[string]any{
169+
"exifInfo": map[string]any{"make": "Canon"},
170+
}), "photo-bytes")
171+
172+
// Simulate an archive written by an older version: original in place,
173+
// sidecar missing the metadata.
174+
assetDir := filepath.Join(dir, "2005", "2005-06")
175+
if err := os.MkdirAll(assetDir, 0o755); err != nil {
176+
t.Fatal(err)
177+
}
178+
target := filepath.Join(assetDir, "IMG_0001.jpg")
179+
if err := os.WriteFile(target, []byte("photo-bytes"), 0o644); err != nil {
180+
t.Fatal(err)
181+
}
182+
if err := os.WriteFile(target+".json", []byte(`{"id":"a1"}`), 0o644); err != nil {
183+
t.Fatal(err)
184+
}
185+
186+
opts := baseOptions(dir)
187+
opts.RefreshSidecars = true
188+
s := &Syncer{Source: src, Options: opts}
189+
stats, err := s.Run(context.Background())
190+
if err != nil {
191+
t.Fatalf("Run: %v", err)
192+
}
193+
if stats.Refreshed != 1 || stats.Downloaded != 0 || stats.Skipped != 0 {
194+
t.Fatalf("stats = %+v", stats)
195+
}
196+
197+
sidecar, err := os.ReadFile(target + ".json")
198+
if err != nil {
199+
t.Fatalf("reading sidecar: %v", err)
200+
}
201+
var decoded map[string]any
202+
if err := json.Unmarshal(sidecar, &decoded); err != nil {
203+
t.Fatalf("sidecar not valid JSON: %s", sidecar)
204+
}
205+
if decoded["exifInfo"] == nil {
206+
t.Fatalf("sidecar was not refreshed with exifInfo: %s", sidecar)
207+
}
208+
209+
// The original must not be re-downloaded or otherwise disturbed.
210+
data, err := os.ReadFile(target)
211+
if err != nil || string(data) != "photo-bytes" {
212+
t.Fatalf("original file changed: %q %v", data, err)
213+
}
214+
}
215+
216+
func TestSyncerRefreshSidecarsDryRunWritesNothing(t *testing.T) {
217+
dir := t.TempDir()
218+
src := newFakeSource()
219+
src.add(mustAsset(t, "a1", "IMG_0001.jpg", "2005-06-15T10:00:00.000Z", map[string]any{
220+
"exifInfo": map[string]any{"make": "Canon"},
221+
}), "photo-bytes")
222+
223+
assetDir := filepath.Join(dir, "2005", "2005-06")
224+
if err := os.MkdirAll(assetDir, 0o755); err != nil {
225+
t.Fatal(err)
226+
}
227+
target := filepath.Join(assetDir, "IMG_0001.jpg")
228+
if err := os.WriteFile(target, []byte("photo-bytes"), 0o644); err != nil {
229+
t.Fatal(err)
230+
}
231+
if err := os.WriteFile(target+".json", []byte(`{"id":"a1"}`), 0o644); err != nil {
232+
t.Fatal(err)
233+
}
234+
235+
opts := baseOptions(dir)
236+
opts.RefreshSidecars = true
237+
opts.DryRun = true
238+
s := &Syncer{Source: src, Options: opts}
239+
stats, err := s.Run(context.Background())
240+
if err != nil {
241+
t.Fatalf("Run: %v", err)
242+
}
243+
if stats.Refreshed != 1 {
244+
t.Fatalf("stats = %+v", stats)
245+
}
246+
sidecar, err := os.ReadFile(target + ".json")
247+
if err != nil {
248+
t.Fatal(err)
249+
}
250+
if string(sidecar) != `{"id":"a1"}` {
251+
t.Fatalf("dry run rewrote sidecar: %s", sidecar)
252+
}
253+
}
254+
141255
func TestSyncerSkipsAlreadyDownloaded(t *testing.T) {
142256
dir := t.TempDir()
143257
src := newFakeSource()

internal/immich/types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,17 @@ func (a *Asset) UnmarshalJSON(data []byte) error {
3939
}
4040

4141
// SearchMetadataQuery is the request body for POST /search/metadata.
42+
//
43+
// WithExif and WithPeople matter more than they look: exifInfo and people are
44+
// optional fields on Immich's asset response, omitted (or left as an empty
45+
// array) unless explicitly requested. Since the sidecar is the server response
46+
// verbatim, leaving them unset silently strips capture date, camera make and
47+
// model, GPS coordinates, rating and faces from every sidecar we write.
4248
type SearchMetadataQuery struct {
4349
Page int `json:"page"`
4450
Size int `json:"size,omitempty"`
4551
WithExif bool `json:"withExif,omitempty"`
52+
WithPeople bool `json:"withPeople,omitempty"`
4653
WithDeleted bool `json:"withDeleted,omitempty"`
4754
IsArchived *bool `json:"isArchived,omitempty"`
4855
PersonalOwn bool `json:"-"` // filtered client-side, Immich search has no such flag

0 commit comments

Comments
 (0)