Skip to content

Commit c13981a

Browse files
committed
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 02c3d1e commit c13981a

File tree

7 files changed

+795
-23
lines changed

7 files changed

+795
-23
lines changed

app/upload/run.go

Lines changed: 152 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)
@@ -118,6 +130,13 @@ func (uc *UpCmd) finishing(ctx context.Context) error {
118130
}
119131
}
120132

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

@@ -141,9 +160,13 @@ func (uc *UpCmd) upload(ctx context.Context, adapter adapters.Reader) error {
141160
uc.albumsCache = cache.NewCollectionCache(50, func(album assets.Album, ids []string) (assets.Album, error) {
142161
return uc.saveAlbum(ctx, album, ids)
143162
})
144-
uc.tagsCache = cache.NewCollectionCache(50, func(tag assets.Tag, ids []string) (assets.Tag, error) {
145-
return uc.saveTags(ctx, tag, ids)
146-
})
163+
if !uc.TagViaSidecar {
164+
uc.tagsCache = cache.NewCollectionCache(50, func(tag assets.Tag, ids []string) (assets.Tag, error) {
165+
return uc.saveTags(ctx, tag, ids)
166+
})
167+
} else {
168+
uc.tagsCache = nil
169+
}
147170

148171
uc.adapter = adapter
149172

@@ -438,6 +461,14 @@ func (uc *UpCmd) uploadAsset(ctx context.Context, a *assets.Asset) (string, erro
438461
for _, tag := range uc.Tags {
439462
a.AddTag(tag)
440463
}
464+
if err := uc.prepareTagsSidecar(ctx, a); err != nil {
465+
if uc.app.FileProcessor() != nil {
466+
uc.app.FileProcessor().RecordAssetError(ctx, a.File, int64(a.FileSize), fileevent.ErrorFileAccess, err)
467+
} else if uc.app.Log() != nil {
468+
uc.app.Log().Error("prepare sidecar failed", "file", a.File, "error", err)
469+
}
470+
return "", err
471+
}
441472

442473
ar, err := uc.client.Immich.AssetUpload(ctx, a)
443474
if err != nil {
@@ -494,6 +525,14 @@ func (uc *UpCmd) uploadAsset(ctx context.Context, a *assets.Asset) (string, erro
494525
// replaceAsset replaces an asset on the server. It uploads the new asset, copies the metadata from the old one and deletes the old one.
495526
// https://github.com/immich-app/immich/pull/23172#issue-3542430029
496527
func (uc *UpCmd) replaceAsset(ctx context.Context, newAsset, oldAsset *assets.Asset) (string, error) {
528+
if err := uc.prepareTagsSidecar(ctx, newAsset); err != nil {
529+
if uc.app.FileProcessor() != nil {
530+
uc.app.FileProcessor().RecordAssetError(ctx, newAsset.File, int64(newAsset.FileSize), fileevent.ErrorFileAccess, err)
531+
} else if uc.app.Log() != nil {
532+
uc.app.Log().Error("prepare sidecar failed", "file", newAsset.File, "error", err)
533+
}
534+
return "", err
535+
}
497536
// 1. Upload the new asset
498537
ar, err := uc.client.Immich.AssetUpload(ctx, newAsset)
499538
if err != nil {
@@ -548,6 +587,9 @@ func (uc *UpCmd) manageAssetAlbums(ctx context.Context, f fshelper.FSAndName, ID
548587
}
549588

550589
func (uc *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) {
590+
if uc.TagViaSidecar {
591+
return
592+
}
551593
if len(a.Tags) == 0 {
552594
return
553595
}
@@ -564,6 +606,111 @@ func (uc *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) {
564606
}
565607
}
566608

609+
func (uc *UpCmd) prepareTagsSidecar(ctx context.Context, a *assets.Asset) error {
610+
if !uc.TagViaSidecar {
611+
return nil
612+
}
613+
614+
loggedTags := make(map[string]struct{})
615+
fp := uc.app.FileProcessor()
616+
for _, t := range a.Tags {
617+
if t.Value == "" {
618+
continue
619+
}
620+
if _, seen := loggedTags[t.Value]; seen {
621+
continue
622+
}
623+
loggedTags[t.Value] = struct{}{}
624+
if fp != nil && fp.Logger() != nil {
625+
fp.Logger().Record(ctx, fileevent.ProcessedTagged, a.File, "tag", t.Value, "method", "sidecar")
626+
}
627+
}
628+
629+
tagSet := make(map[string]struct{})
630+
for value := range loggedTags {
631+
tagSet[value] = struct{}{}
632+
}
633+
for _, t := range a.Tags {
634+
if t.Value != "" {
635+
tagSet[t.Value] = struct{}{}
636+
}
637+
}
638+
if a.FromSideCar != nil {
639+
for _, t := range a.FromSideCar.Tags {
640+
if t.Value != "" {
641+
tagSet[t.Value] = struct{}{}
642+
}
643+
}
644+
}
645+
646+
tagValues := make([]string, 0, len(tagSet))
647+
for value := range tagSet {
648+
tagValues = append(tagValues, value)
649+
}
650+
sort.Strings(tagValues)
651+
652+
if len(tagValues) == 0 {
653+
uc.app.Log().Debug("tag via sidecar skipped", "file", a.OriginalFileName, "reason", "no tags")
654+
return nil
655+
}
656+
657+
tagList := strings.Join(tagValues, ", ")
658+
if len(loggedTags) > 0 {
659+
uc.app.Log().Info("tag via sidecar applied", "file", a.OriginalFileName, "tags", tagList)
660+
} else {
661+
uc.app.Log().Debug("tag via sidecar preserved existing tags", "file", a.OriginalFileName, "tags", tagList)
662+
}
663+
664+
var source []byte
665+
if a.FromSideCar != nil && a.FromSideCar.File.FS() != nil && a.FromSideCar.File.Name() != "" {
666+
f, err := a.FromSideCar.File.Open()
667+
if err != nil {
668+
return fmt.Errorf("open existing sidecar: %w", err)
669+
}
670+
source, err = io.ReadAll(f)
671+
_ = f.Close()
672+
if err != nil {
673+
return fmt.Errorf("read existing sidecar: %w", err)
674+
}
675+
}
676+
677+
if len(source) == 0 && len(tagValues) == 0 {
678+
return nil
679+
}
680+
681+
updated, err := xmpsidecar.UpdateTags(source, tagValues)
682+
if err != nil {
683+
return fmt.Errorf("update sidecar tags: %w", err)
684+
}
685+
686+
if uc.tagSidecarDir == "" {
687+
dir, dirErr := os.MkdirTemp("", "immich-go-tag-sidecars-*")
688+
if dirErr != nil {
689+
return fmt.Errorf("create temporary sidecar directory: %w", dirErr)
690+
}
691+
uc.tagSidecarDir = dir
692+
}
693+
694+
fileName := path.Base(a.OriginalFileName) + ".xmp"
695+
fullPath := filepath.Join(uc.tagSidecarDir, fileName)
696+
if writeErr := os.WriteFile(fullPath, updated, 0o600); writeErr != nil {
697+
return fmt.Errorf("write temporary sidecar: %w", writeErr)
698+
}
699+
700+
uc.app.Log().Debug("tag via sidecar wrote temporary sidecar", "file", a.OriginalFileName, "path", fullPath)
701+
702+
md := &assets.Metadata{}
703+
if err = xmpsidecar.ReadXMP(bytes.NewReader(updated), md); err != nil {
704+
return fmt.Errorf("reload updated sidecar metadata: %w", err)
705+
}
706+
707+
fs := osfs.DirFS(uc.tagSidecarDir)
708+
md.File = fshelper.FSName(fs, fileName)
709+
a.FromSideCar = a.UseMetadata(md)
710+
711+
return nil
712+
}
713+
567714
func (uc *UpCmd) DeleteServerAssets(ctx context.Context, ids []string) error {
568715
uc.app.Log().Message("%d server assets to delete.", len(ids))
569716
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
@@ -55,12 +55,14 @@ type UpCmd struct {
5555
// Cli flags
5656

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

6567
// Upload command state
6668
// Filters []filters.Filter
@@ -85,6 +87,7 @@ func (uc *UpCmd) RegisterFlags(flags *pflag.FlagSet) {
8587
flags.BoolVar(&uc.Overwrite, "overwrite", false, "Always overwrite files on the server with local versions")
8688
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')")
8789
flags.BoolVar(&uc.SessionTag, "session-tag", false, "Tag uploaded photos with a tag \"{immich-go}/YYYY-MM-DD HH-MM-SS\"")
90+
flags.BoolVar(&uc.TagViaSidecar, "tag-via-sidecar", false, "Write tags to a temporary XMP sidecar instead of creating tags in Immich")
8891

8992
uc.StackOptions.RegisterFlags(flags)
9093
}

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)