Skip to content

Commit 9cfe87e

Browse files
committed
feat: add --tag-via-sidecar upload flag
Write tags into a temporary XMP sidecar instead of using Immich tag APIs as a workaround for [Immich #16747](immich-app/immich#16747)
1 parent 1c65d89 commit 9cfe87e

File tree

7 files changed

+793
-23
lines changed

7 files changed

+793
-23
lines changed

app/upload/run.go

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package upload
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
8+
"io"
9+
"os"
10+
"path"
11+
"path/filepath"
12+
"sort"
713
"strings"
814
"sync"
915

@@ -12,9 +18,11 @@ import (
1218
"github.com/simulot/immich-go/immich"
1319
"github.com/simulot/immich-go/internal/assets"
1420
"github.com/simulot/immich-go/internal/assets/cache"
21+
"github.com/simulot/immich-go/internal/exif/sidecars/xmpsidecar"
1522
"github.com/simulot/immich-go/internal/fileevent"
1623
"github.com/simulot/immich-go/internal/filters"
1724
"github.com/simulot/immich-go/internal/fshelper"
25+
"github.com/simulot/immich-go/internal/fshelper/osfs"
1826
"github.com/simulot/immich-go/internal/worker"
1927
)
2028

@@ -98,8 +106,12 @@ func (uc *UpCmd) finishing(ctx context.Context) error {
98106
}
99107
defer func() { uc.finished = true }()
100108
// do waiting operations
101-
uc.albumsCache.Close()
102-
uc.tagsCache.Close()
109+
if uc.albumsCache != nil {
110+
uc.albumsCache.Close()
111+
}
112+
if uc.tagsCache != nil {
113+
uc.tagsCache.Close()
114+
}
103115

104116
// Resume immich background jobs if requested
105117
err := uc.resumeJobs(ctx)
@@ -116,6 +128,13 @@ func (uc *UpCmd) finishing(ctx context.Context) error {
116128
}
117129
}
118130

131+
if uc.tagSidecarDir != "" {
132+
if remErr := os.RemoveAll(uc.tagSidecarDir); remErr != nil {
133+
uc.app.Log().Warn("failed to clean temporary tag sidecar directory", "dir", uc.tagSidecarDir, "err", remErr)
134+
}
135+
uc.tagSidecarDir = ""
136+
}
137+
119138
return nil
120139
}
121140

@@ -137,9 +156,13 @@ func (uc *UpCmd) upload(ctx context.Context, adapter adapters.Reader) error {
137156
uc.albumsCache = cache.NewCollectionCache(50, func(album assets.Album, ids []string) (assets.Album, error) {
138157
return uc.saveAlbum(ctx, album, ids)
139158
})
140-
uc.tagsCache = cache.NewCollectionCache(50, func(tag assets.Tag, ids []string) (assets.Tag, error) {
141-
return uc.saveTags(ctx, tag, ids)
142-
})
159+
if !uc.TagViaSidecar {
160+
uc.tagsCache = cache.NewCollectionCache(50, func(tag assets.Tag, ids []string) (assets.Tag, error) {
161+
return uc.saveTags(ctx, tag, ids)
162+
})
163+
} else {
164+
uc.tagsCache = nil
165+
}
143166

144167
uc.adapter = adapter
145168

@@ -424,6 +447,10 @@ func (uc *UpCmd) uploadAsset(ctx context.Context, a *assets.Asset) (string, erro
424447
for _, tag := range uc.Tags {
425448
a.AddTag(tag)
426449
}
450+
if err := uc.prepareTagsSidecar(ctx, a); err != nil {
451+
uc.app.Jnl().Record(ctx, fileevent.Error, a.File, "error", err.Error())
452+
return "", err
453+
}
427454

428455
ar, err := uc.client.Immich.AssetUpload(ctx, a)
429456
if err != nil {
@@ -471,6 +498,10 @@ func (uc *UpCmd) uploadAsset(ctx context.Context, a *assets.Asset) (string, erro
471498

472499
func (uc *UpCmd) replaceAsset(ctx context.Context, ID string, a, old *assets.Asset) (string, error) {
473500
defer uc.app.Log().Debug("replaced by", "ID", ID, "file", a)
501+
if err := uc.prepareTagsSidecar(ctx, a); err != nil {
502+
uc.app.Jnl().Record(ctx, fileevent.Error, a.File, "error", err.Error())
503+
return "", err
504+
}
474505
ar, err := uc.client.Immich.ReplaceAsset(ctx, ID, a)
475506
if err != nil {
476507
uc.app.Jnl().Record(ctx, fileevent.UploadServerError, a.File, "error", err.Error())
@@ -513,6 +544,9 @@ func (uc *UpCmd) manageAssetAlbums(ctx context.Context, f fshelper.FSAndName, ID
513544
}
514545

515546
func (uc *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) {
547+
if uc.TagViaSidecar {
548+
return
549+
}
516550
if len(a.Tags) == 0 {
517551
return
518552
}
@@ -528,6 +562,117 @@ func (uc *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) {
528562
}
529563
}
530564

565+
func (uc *UpCmd) prepareTagsSidecar(ctx context.Context, a *assets.Asset) error {
566+
if !uc.TagViaSidecar {
567+
return nil
568+
}
569+
570+
loggedTags := make(map[string]struct{})
571+
if uc.app.Jnl() != nil {
572+
for _, t := range a.Tags {
573+
if t.Value == "" {
574+
continue
575+
}
576+
if _, seen := loggedTags[t.Value]; seen {
577+
continue
578+
}
579+
loggedTags[t.Value] = struct{}{}
580+
uc.app.Jnl().Record(ctx, fileevent.Tagged, a.File, "tag", t.Value, "method", "sidecar")
581+
}
582+
} else {
583+
for _, t := range a.Tags {
584+
if t.Value == "" {
585+
continue
586+
}
587+
loggedTags[t.Value] = struct{}{}
588+
}
589+
}
590+
591+
tagSet := make(map[string]struct{})
592+
for value := range loggedTags {
593+
tagSet[value] = struct{}{}
594+
}
595+
for _, t := range a.Tags {
596+
if t.Value != "" {
597+
tagSet[t.Value] = struct{}{}
598+
}
599+
}
600+
if a.FromSideCar != nil {
601+
for _, t := range a.FromSideCar.Tags {
602+
if t.Value != "" {
603+
tagSet[t.Value] = struct{}{}
604+
}
605+
}
606+
}
607+
608+
tagValues := make([]string, 0, len(tagSet))
609+
for value := range tagSet {
610+
tagValues = append(tagValues, value)
611+
}
612+
sort.Strings(tagValues)
613+
614+
if len(tagValues) == 0 {
615+
uc.app.Log().Debug("tag via sidecar skipped", "file", a.OriginalFileName, "reason", "no tags")
616+
return nil
617+
}
618+
619+
tagList := strings.Join(tagValues, ", ")
620+
if len(loggedTags) > 0 {
621+
uc.app.Log().Info("tag via sidecar applied", "file", a.OriginalFileName, "tags", tagList)
622+
} else {
623+
uc.app.Log().Debug("tag via sidecar preserved existing tags", "file", a.OriginalFileName, "tags", tagList)
624+
}
625+
626+
var source []byte
627+
if a.FromSideCar != nil && a.FromSideCar.File.FS() != nil && a.FromSideCar.File.Name() != "" {
628+
f, err := a.FromSideCar.File.Open()
629+
if err != nil {
630+
return fmt.Errorf("open existing sidecar: %w", err)
631+
}
632+
source, err = io.ReadAll(f)
633+
_ = f.Close()
634+
if err != nil {
635+
return fmt.Errorf("read existing sidecar: %w", err)
636+
}
637+
}
638+
639+
if len(source) == 0 && len(tagValues) == 0 {
640+
return nil
641+
}
642+
643+
updated, err := xmpsidecar.UpdateTags(source, tagValues)
644+
if err != nil {
645+
return fmt.Errorf("update sidecar tags: %w", err)
646+
}
647+
648+
if uc.tagSidecarDir == "" {
649+
dir, dirErr := os.MkdirTemp("", "immich-go-tag-sidecars-*")
650+
if dirErr != nil {
651+
return fmt.Errorf("create temporary sidecar directory: %w", dirErr)
652+
}
653+
uc.tagSidecarDir = dir
654+
}
655+
656+
fileName := path.Base(a.OriginalFileName) + ".xmp"
657+
fullPath := filepath.Join(uc.tagSidecarDir, fileName)
658+
if writeErr := os.WriteFile(fullPath, updated, 0o600); writeErr != nil {
659+
return fmt.Errorf("write temporary sidecar: %w", writeErr)
660+
}
661+
662+
uc.app.Log().Debug("tag via sidecar wrote temporary sidecar", "file", a.OriginalFileName, "path", fullPath)
663+
664+
md := &assets.Metadata{}
665+
if err = xmpsidecar.ReadXMP(bytes.NewReader(updated), md); err != nil {
666+
return fmt.Errorf("reload updated sidecar metadata: %w", err)
667+
}
668+
669+
fs := osfs.DirFS(uc.tagSidecarDir)
670+
md.File = fshelper.FSName(fs, fileName)
671+
a.FromSideCar = a.UseMetadata(md)
672+
673+
return nil
674+
}
675+
531676
func (uc *UpCmd) DeleteServerAssets(ctx context.Context, ids []string) error {
532677
uc.app.Log().Message("%d server assets to delete.", len(ids))
533678
return uc.client.Immich.DeleteAssets(ctx, ids, false)

app/upload/upload.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,14 @@ type UpCmd struct {
5353
// Cli flags
5454

5555
shared.StackOptions
56-
client app.Client
57-
NoUI bool // Disable UI
58-
Overwrite bool // Always overwrite files on the server with local versions
59-
Tags []string
60-
SessionTag bool
61-
session string // Session tag value
56+
client app.Client
57+
NoUI bool // Disable UI
58+
Overwrite bool // Always overwrite files on the server with local versions
59+
Tags []string
60+
SessionTag bool
61+
TagViaSidecar bool
62+
session string // Session tag value
63+
tagSidecarDir string
6264

6365
// Upload command state
6466
// Filters []filters.Filter
@@ -83,6 +85,7 @@ func (uc *UpCmd) RegisterFlags(flags *pflag.FlagSet) {
8385
flags.BoolVar(&uc.Overwrite, "overwrite", false, "Always overwrite files on the server with local versions")
8486
flags.StringSliceVar(&uc.Tags, "tag", nil, "Add tags to the imported assets. Can be specified multiple times. Hierarchy is supported using a / separator (e.g. 'tag1/subtag1')")
8587
flags.BoolVar(&uc.SessionTag, "session-tag", false, "Tag uploaded photos with a tag \"{immich-go}/YYYY-MM-DD HH-MM-SS\"")
88+
flags.BoolVar(&uc.TagViaSidecar, "tag-via-sidecar", false, "Write tags to a temporary XMP sidecar instead of creating tags in Immich")
8689

8790
uc.StackOptions.RegisterFlags(flags)
8891
}

docs/commands/upload.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ All upload sub-commands require these connection parameters:
4141

4242
## Tagging and Organization
4343

44-
| Option | Default | Description |
45-
| --------------- | ------------ | -------------------------------------------- |
46-
| `--session-tag` | `false` | Tag with upload session timestamp |
47-
| `--tag` | - | Add custom tags (can be used multiple times) |
48-
| `--device-uuid` | `$LOCALHOST` | Set device identifier |
44+
| Option | Default | Description |
45+
| ------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------|
46+
| `--session-tag` | `false` | Tag with upload session timestamp |
47+
| `--tag` | - | Add custom tags (can be used multiple times) |
48+
| `--tag-via-sidecar`| `false` | Write tags into a temporary XMP sidecar instead of using Immich tag APIs (workaround for [Immich #16747](https://github.com/immich-app/immich/issues/16747)) |
49+
| `--device-uuid` | `$LOCALHOST` | Set device identifier |
4950

5051
## User Interface
5152

@@ -270,4 +271,4 @@ immich-go upload from-immich \
270271

271272
- [Configuration Options](../configuration.md)
272273
- [Technical Details](../technical.md)
273-
- [Best Practices](../best-practices.md)
274+
- [Best Practices](../best-practices.md)

internal/exif/sidecars/xmpsidecar/read.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,31 +42,35 @@ func walk(m mxj.Map, md *assets.Metadata, path string) {
4242
}
4343
}
4444

45-
var reDescription = regexp.MustCompile(`/xmpmeta/RDF/Description\[\d+\]/`)
45+
var (
46+
reDescription = regexp.MustCompile(`/xmpmeta/RDF/Description(?:\[\d+\])?/`)
47+
reIndex = regexp.MustCompile(`\[\d+\]`)
48+
)
4649

4750
func filter(md *assets.Metadata, p string, value string) {
4851
p = reDescription.ReplaceAllString(p, "")
52+
p = reIndex.ReplaceAllString(p, "")
4953
// debug fmt.Printf("%s: %s\n", p, value)
5054
switch p {
51-
case "DateTimeOriginal":
55+
case "DateTimeOriginal", "DateTimeOriginal/#text":
5256
if d, err := TimeStringToTime(value, time.UTC); err == nil {
5357
md.DateTaken = d
5458
}
5559
case "ImageDescription/Alt/li/#text":
5660
md.Description = value
57-
case "Rating":
61+
case "Rating", "Rating/#text":
5862
md.Rating = StringToByte(value)
59-
case "TagsList/Seq/li":
63+
case "TagsList/Seq/li", "TagsList/Seq/li/#text":
6064
md.Tags = append(md.Tags,
6165
assets.Tag{
6266
Name: path.Base(value),
6367
Value: value,
6468
})
65-
case "/xmpmeta/RDF/Description/GPSLatitude":
69+
case "/xmpmeta/RDF/Description/GPSLatitude", "/xmpmeta/RDF/Description/GPSLatitude/#text", "GPSLatitude", "GPSLatitude/#text":
6670
if f, err := GPTStringToFloat(value); err == nil {
6771
md.Latitude = f
6872
}
69-
case "/xmpmeta/RDF/Description/GPSLongitude":
73+
case "/xmpmeta/RDF/Description/GPSLongitude", "/xmpmeta/RDF/Description/GPSLongitude/#text", "GPSLongitude", "GPSLongitude/#text":
7074
if f, err := GPTStringToFloat(value); err == nil {
7175
md.Longitude = f
7276
}

0 commit comments

Comments
 (0)