diff --git a/app/upload/run.go b/app/upload/run.go
index d7996187..367ee411 100644
--- a/app/upload/run.go
+++ b/app/upload/run.go
@@ -1,9 +1,15 @@
package upload
import (
+ "bytes"
"context"
"errors"
"fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
"strings"
"sync"
@@ -12,9 +18,11 @@ import (
"github.com/simulot/immich-go/immich"
"github.com/simulot/immich-go/internal/assets"
"github.com/simulot/immich-go/internal/assets/cache"
+ "github.com/simulot/immich-go/internal/exif/sidecars/xmpsidecar"
"github.com/simulot/immich-go/internal/fileevent"
"github.com/simulot/immich-go/internal/filters"
"github.com/simulot/immich-go/internal/fshelper"
+ "github.com/simulot/immich-go/internal/fshelper/osfs"
"github.com/simulot/immich-go/internal/worker"
)
@@ -98,8 +106,12 @@ func (uc *UpCmd) finishing(ctx context.Context) error {
}
defer func() { uc.finished = true }()
// do waiting operations
- uc.albumsCache.Close()
- uc.tagsCache.Close()
+ if uc.albumsCache != nil {
+ uc.albumsCache.Close()
+ }
+ if uc.tagsCache != nil {
+ uc.tagsCache.Close()
+ }
// Resume immich background jobs if requested
err := uc.resumeJobs(ctx)
@@ -118,6 +130,13 @@ func (uc *UpCmd) finishing(ctx context.Context) error {
}
}
+ if uc.tagSidecarDir != "" {
+ if remErr := os.RemoveAll(uc.tagSidecarDir); remErr != nil {
+ uc.app.Log().Warn("failed to clean temporary tag sidecar directory", "dir", uc.tagSidecarDir, "err", remErr)
+ }
+ uc.tagSidecarDir = ""
+ }
+
return nil
}
@@ -141,9 +160,13 @@ func (uc *UpCmd) upload(ctx context.Context, adapter adapters.Reader) error {
uc.albumsCache = cache.NewCollectionCache(50, func(album assets.Album, ids []string) (assets.Album, error) {
return uc.saveAlbum(ctx, album, ids)
})
- uc.tagsCache = cache.NewCollectionCache(50, func(tag assets.Tag, ids []string) (assets.Tag, error) {
- return uc.saveTags(ctx, tag, ids)
- })
+ if !uc.TagViaSidecar {
+ uc.tagsCache = cache.NewCollectionCache(50, func(tag assets.Tag, ids []string) (assets.Tag, error) {
+ return uc.saveTags(ctx, tag, ids)
+ })
+ } else {
+ uc.tagsCache = nil
+ }
uc.adapter = adapter
@@ -438,6 +461,14 @@ func (uc *UpCmd) uploadAsset(ctx context.Context, a *assets.Asset) (string, erro
for _, tag := range uc.Tags {
a.AddTag(tag)
}
+ if err := uc.prepareTagsSidecar(ctx, a); err != nil {
+ if uc.app.FileProcessor() != nil {
+ uc.app.FileProcessor().RecordAssetError(ctx, a.File, int64(a.FileSize), fileevent.ErrorFileAccess, err)
+ } else if uc.app.Log() != nil {
+ uc.app.Log().Error("prepare sidecar failed", "file", a.File, "error", err)
+ }
+ return "", err
+ }
ar, err := uc.client.Immich.AssetUpload(ctx, a)
if err != nil {
@@ -494,6 +525,14 @@ func (uc *UpCmd) uploadAsset(ctx context.Context, a *assets.Asset) (string, erro
// replaceAsset replaces an asset on the server. It uploads the new asset, copies the metadata from the old one and deletes the old one.
// https://github.com/immich-app/immich/pull/23172#issue-3542430029
func (uc *UpCmd) replaceAsset(ctx context.Context, newAsset, oldAsset *assets.Asset) (string, error) {
+ if err := uc.prepareTagsSidecar(ctx, newAsset); err != nil {
+ if uc.app.FileProcessor() != nil {
+ uc.app.FileProcessor().RecordAssetError(ctx, newAsset.File, int64(newAsset.FileSize), fileevent.ErrorFileAccess, err)
+ } else if uc.app.Log() != nil {
+ uc.app.Log().Error("prepare sidecar failed", "file", newAsset.File, "error", err)
+ }
+ return "", err
+ }
// 1. Upload the new asset
ar, err := uc.client.Immich.AssetUpload(ctx, newAsset)
if err != nil {
@@ -548,6 +587,9 @@ func (uc *UpCmd) manageAssetAlbums(ctx context.Context, f fshelper.FSAndName, ID
}
func (uc *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) {
+ if uc.TagViaSidecar {
+ return
+ }
if len(a.Tags) == 0 {
return
}
@@ -564,6 +606,111 @@ func (uc *UpCmd) manageAssetTags(ctx context.Context, a *assets.Asset) {
}
}
+func (uc *UpCmd) prepareTagsSidecar(ctx context.Context, a *assets.Asset) error {
+ if !uc.TagViaSidecar {
+ return nil
+ }
+
+ loggedTags := make(map[string]struct{})
+ fp := uc.app.FileProcessor()
+ for _, t := range a.Tags {
+ if t.Value == "" {
+ continue
+ }
+ if _, seen := loggedTags[t.Value]; seen {
+ continue
+ }
+ loggedTags[t.Value] = struct{}{}
+ if fp != nil && fp.Logger() != nil {
+ fp.Logger().Record(ctx, fileevent.ProcessedTagged, a.File, "tag", t.Value, "method", "sidecar")
+ }
+ }
+
+ tagSet := make(map[string]struct{})
+ for value := range loggedTags {
+ tagSet[value] = struct{}{}
+ }
+ for _, t := range a.Tags {
+ if t.Value != "" {
+ tagSet[t.Value] = struct{}{}
+ }
+ }
+ if a.FromSideCar != nil {
+ for _, t := range a.FromSideCar.Tags {
+ if t.Value != "" {
+ tagSet[t.Value] = struct{}{}
+ }
+ }
+ }
+
+ tagValues := make([]string, 0, len(tagSet))
+ for value := range tagSet {
+ tagValues = append(tagValues, value)
+ }
+ sort.Strings(tagValues)
+
+ if len(tagValues) == 0 {
+ uc.app.Log().Debug("tag via sidecar skipped", "file", a.OriginalFileName, "reason", "no tags")
+ return nil
+ }
+
+ tagList := strings.Join(tagValues, ", ")
+ if len(loggedTags) > 0 {
+ uc.app.Log().Info("tag via sidecar applied", "file", a.OriginalFileName, "tags", tagList)
+ } else {
+ uc.app.Log().Debug("tag via sidecar preserved existing tags", "file", a.OriginalFileName, "tags", tagList)
+ }
+
+ var source []byte
+ if a.FromSideCar != nil && a.FromSideCar.File.FS() != nil && a.FromSideCar.File.Name() != "" {
+ f, err := a.FromSideCar.File.Open()
+ if err != nil {
+ return fmt.Errorf("open existing sidecar: %w", err)
+ }
+ source, err = io.ReadAll(f)
+ _ = f.Close()
+ if err != nil {
+ return fmt.Errorf("read existing sidecar: %w", err)
+ }
+ }
+
+ if len(source) == 0 && len(tagValues) == 0 {
+ return nil
+ }
+
+ updated, err := xmpsidecar.UpdateTags(source, tagValues)
+ if err != nil {
+ return fmt.Errorf("update sidecar tags: %w", err)
+ }
+
+ if uc.tagSidecarDir == "" {
+ dir, dirErr := os.MkdirTemp("", "immich-go-tag-sidecars-*")
+ if dirErr != nil {
+ return fmt.Errorf("create temporary sidecar directory: %w", dirErr)
+ }
+ uc.tagSidecarDir = dir
+ }
+
+ fileName := path.Base(a.OriginalFileName) + ".xmp"
+ fullPath := filepath.Join(uc.tagSidecarDir, fileName)
+ if writeErr := os.WriteFile(fullPath, updated, 0o600); writeErr != nil {
+ return fmt.Errorf("write temporary sidecar: %w", writeErr)
+ }
+
+ uc.app.Log().Debug("tag via sidecar wrote temporary sidecar", "file", a.OriginalFileName, "path", fullPath)
+
+ md := &assets.Metadata{}
+ if err = xmpsidecar.ReadXMP(bytes.NewReader(updated), md); err != nil {
+ return fmt.Errorf("reload updated sidecar metadata: %w", err)
+ }
+
+ fs := osfs.DirFS(uc.tagSidecarDir)
+ md.File = fshelper.FSName(fs, fileName)
+ a.FromSideCar = a.UseMetadata(md)
+
+ return nil
+}
+
func (uc *UpCmd) DeleteServerAssets(ctx context.Context, ids []string) error {
uc.app.Log().Message("%d server assets to delete.", len(ids))
return uc.client.Immich.DeleteAssets(ctx, ids, false)
diff --git a/app/upload/upload.go b/app/upload/upload.go
index aed3cec7..8bedadb4 100644
--- a/app/upload/upload.go
+++ b/app/upload/upload.go
@@ -55,12 +55,14 @@ type UpCmd struct {
// Cli flags
shared.StackOptions
- client app.Client
- NoUI bool // Disable UI
- Overwrite bool // Always overwrite files on the server with local versions
- Tags []string
- SessionTag bool
- session string // Session tag value
+ client app.Client
+ NoUI bool // Disable UI
+ Overwrite bool // Always overwrite files on the server with local versions
+ Tags []string
+ SessionTag bool
+ TagViaSidecar bool
+ session string // Session tag value
+ tagSidecarDir string
// Upload command state
// Filters []filters.Filter
@@ -85,6 +87,7 @@ func (uc *UpCmd) RegisterFlags(flags *pflag.FlagSet) {
flags.BoolVar(&uc.Overwrite, "overwrite", false, "Always overwrite files on the server with local versions")
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')")
flags.BoolVar(&uc.SessionTag, "session-tag", false, "Tag uploaded photos with a tag \"{immich-go}/YYYY-MM-DD HH-MM-SS\"")
+ flags.BoolVar(&uc.TagViaSidecar, "tag-via-sidecar", false, "Write tags to a temporary XMP sidecar instead of creating tags in Immich")
uc.StackOptions.RegisterFlags(flags)
}
diff --git a/docs/commands/upload.md b/docs/commands/upload.md
index 62d26c33..0037b743 100644
--- a/docs/commands/upload.md
+++ b/docs/commands/upload.md
@@ -41,11 +41,12 @@ All upload sub-commands require these connection parameters:
## Tagging and Organization
-| Option | Default | Description |
-| --------------- | ------------ | -------------------------------------------- |
-| `--session-tag` | `false` | Tag with upload session timestamp |
-| `--tag` | - | Add custom tags (can be used multiple times) |
-| `--device-uuid` | `$LOCALHOST` | Set device identifier |
+| Option | Default | Description |
+| ------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `--session-tag` | `false` | Tag with upload session timestamp |
+| `--tag` | - | Add custom tags (can be used multiple times) |
+| `--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)) |
+| `--device-uuid` | `$LOCALHOST` | Set device identifier |
## User Interface
@@ -270,4 +271,4 @@ immich-go upload from-immich \
- [Configuration Options](../configuration.md)
- [Technical Details](../technical.md)
-- [Best Practices](../best-practices.md)
\ No newline at end of file
+- [Best Practices](../best-practices.md)
diff --git a/internal/exif/sidecars/xmpsidecar/read.go b/internal/exif/sidecars/xmpsidecar/read.go
index 037398d7..c8fdc11a 100644
--- a/internal/exif/sidecars/xmpsidecar/read.go
+++ b/internal/exif/sidecars/xmpsidecar/read.go
@@ -42,31 +42,35 @@ func walk(m mxj.Map, md *assets.Metadata, path string) {
}
}
-var reDescription = regexp.MustCompile(`/xmpmeta/RDF/Description\[\d+\]/`)
+var (
+ reDescription = regexp.MustCompile(`/xmpmeta/RDF/Description(?:\[\d+\])?/`)
+ reIndex = regexp.MustCompile(`\[\d+\]`)
+)
func filter(md *assets.Metadata, p string, value string) {
p = reDescription.ReplaceAllString(p, "")
+ p = reIndex.ReplaceAllString(p, "")
// debug fmt.Printf("%s: %s\n", p, value)
switch p {
- case "DateTimeOriginal":
+ case "DateTimeOriginal", "DateTimeOriginal/#text":
if d, err := TimeStringToTime(value, time.UTC); err == nil {
md.DateTaken = d
}
case "ImageDescription/Alt/li/#text":
md.Description = value
- case "Rating":
+ case "Rating", "Rating/#text":
md.Rating = StringToByte(value)
- case "TagsList/Seq/li":
+ case "TagsList/Seq/li", "TagsList/Seq/li/#text":
md.Tags = append(md.Tags,
assets.Tag{
Name: path.Base(value),
Value: value,
})
- case "/xmpmeta/RDF/Description/GPSLatitude":
+ case "/xmpmeta/RDF/Description/GPSLatitude", "/xmpmeta/RDF/Description/GPSLatitude/#text", "GPSLatitude", "GPSLatitude/#text":
if f, err := GPTStringToFloat(value); err == nil {
md.Latitude = f
}
- case "/xmpmeta/RDF/Description/GPSLongitude":
+ case "/xmpmeta/RDF/Description/GPSLongitude", "/xmpmeta/RDF/Description/GPSLongitude/#text", "GPSLongitude", "GPSLongitude/#text":
if f, err := GPTStringToFloat(value); err == nil {
md.Longitude = f
}
diff --git a/internal/exif/sidecars/xmpsidecar/testdata/C0092.xmp b/internal/exif/sidecars/xmpsidecar/testdata/C0092.xmp
new file mode 100644
index 00000000..80bafe04
--- /dev/null
+++ b/internal/exif/sidecars/xmpsidecar/testdata/C0092.xmp
@@ -0,0 +1,152 @@
+
+
+
+
+ 2025-08-28T23:31:20-07:00
+ 2025-08-28T23:31:20-07:00
+ 2025-08-28T23:30:53-07:00
+ Sony
+ ILCE-6700
+ AVC_1920_1080_HP@L41
+
+ 1920
+ 1080
+ pixel
+
+ Stereo
+ 16Int
+ WGS-84
+ 0
+ 2.2.0.0
+ 2025-08-28T23:31:20-07:00
+ 2025-08-28T23:30:53-07:00
+
+ Sony
+ ILCE-6700
+
+ 624
+
+ DIRECT
+
+ AVC_1920_1080_HP@L41
+ 23.98p
+ 23.98p
+
+
+ 1920
+ 1080
+ 16:9
+
+
+
+ 2
+
+
+
+ DIRECT
+ LPCM16
+ CH1
+
+
+ DIRECT
+ LPCM16
+ CH2
+
+
+
+
+
+ normal
+ false
+
+
+ 24
+ false
+
+
+
+ 0
+ 12282823
+ increment
+
+
+ 623
+ 11542823
+ end
+
+
+
+
+
+ s-cinetone
+ rec709
+ rec709
+
+
+ 2.2.0.0
+ V
+ WGS-84
+ 0
+
+
+
+
+ ImagerControlInformation
+
+
+
+ 0
+ start
+
+
+
+
+
+ LensControlInformation
+
+
+
+ 0
+ start
+
+
+
+
+
+ DistortionCorrection
+
+
+
+ 0
+ start
+
+
+
+
+
+ Gyroscope
+
+
+
+ 0
+ start
+
+
+
+
+
+ Accelerometor
+
+
+
+ 0
+ start
+
+
+
+
+
+
+
+
+
diff --git a/internal/exif/sidecars/xmpsidecar/write.go b/internal/exif/sidecars/xmpsidecar/write.go
new file mode 100644
index 00000000..e865a5c1
--- /dev/null
+++ b/internal/exif/sidecars/xmpsidecar/write.go
@@ -0,0 +1,342 @@
+package xmpsidecar
+
+import (
+ "bytes"
+ "encoding/xml"
+ "errors"
+ "io"
+)
+
+const (
+ namespaceRDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ namespaceDigiKam = "http://www.digikam.org/ns/1.0/"
+ namespaceX = "adobe:ns:meta/"
+)
+
+type namespaceDecl struct {
+ uri string
+ previous string
+ hadPrev bool
+}
+
+type namespaceState struct {
+ stack [][]namespaceDecl
+ uriToPrefix map[string]string
+}
+
+func newNamespaceState() *namespaceState {
+ return &namespaceState{
+ stack: nil,
+ uriToPrefix: map[string]string{
+ "http://www.w3.org/XML/1998/namespace": "xml",
+ },
+ }
+}
+
+func (ns *namespaceState) push(start xml.StartElement) {
+ var decls []namespaceDecl
+ for _, attr := range start.Attr {
+ if attr.Name.Space == "xmlns" || (attr.Name.Space == "" && attr.Name.Local == "xmlns") {
+ prefix := attr.Name.Local
+ if attr.Name.Space == "" && attr.Name.Local == "xmlns" {
+ prefix = ""
+ }
+ uri := attr.Value
+ prev, ok := ns.uriToPrefix[uri]
+ decls = append(decls, namespaceDecl{uri: uri, previous: prev, hadPrev: ok})
+ ns.uriToPrefix[uri] = prefix
+ }
+ }
+ ns.stack = append(ns.stack, decls)
+}
+
+func (ns *namespaceState) pop() {
+ if len(ns.stack) == 0 {
+ return
+ }
+ decls := ns.stack[len(ns.stack)-1]
+ ns.stack = ns.stack[:len(ns.stack)-1]
+ for i := len(decls) - 1; i >= 0; i-- {
+ decl := decls[i]
+ if decl.hadPrev {
+ ns.uriToPrefix[decl.uri] = decl.previous
+ } else {
+ delete(ns.uriToPrefix, decl.uri)
+ }
+ }
+}
+
+func (ns *namespaceState) encodeStart(encoder *xml.Encoder, start xml.StartElement) error {
+ ns.push(start)
+ return encoder.EncodeToken(ns.convertStart(start))
+}
+
+func (ns *namespaceState) encodeEnd(encoder *xml.Encoder, end xml.EndElement) error {
+ converted := ns.convertEnd(end)
+ ns.pop()
+ return encoder.EncodeToken(converted)
+}
+
+func (ns *namespaceState) convertStart(start xml.StartElement) xml.StartElement {
+ converted := xml.StartElement{
+ Name: ns.convertName(start.Name),
+ Attr: make([]xml.Attr, len(start.Attr)),
+ }
+ for i, attr := range start.Attr {
+ converted.Attr[i] = ns.convertAttr(attr)
+ }
+ return converted
+}
+
+func (ns *namespaceState) convertEnd(end xml.EndElement) xml.EndElement {
+ return xml.EndElement{Name: ns.convertName(end.Name)}
+}
+
+func (ns *namespaceState) convertAttr(attr xml.Attr) xml.Attr {
+ switch {
+ case attr.Name.Space == "" && attr.Name.Local == "xmlns":
+ return xml.Attr{Name: xml.Name{Local: "xmlns"}, Value: attr.Value}
+ case attr.Name.Space == "xmlns":
+ local := attr.Name.Local
+ if local == "" {
+ return xml.Attr{Name: xml.Name{Local: "xmlns"}, Value: attr.Value}
+ }
+ return xml.Attr{Name: xml.Name{Local: "xmlns:" + local}, Value: attr.Value}
+ default:
+ return xml.Attr{Name: ns.convertName(attr.Name), Value: attr.Value}
+ }
+}
+
+func (ns *namespaceState) convertName(name xml.Name) xml.Name {
+ if name.Local == "" {
+ return xml.Name{}
+ }
+ if name.Space == "" {
+ return xml.Name{Local: name.Local}
+ }
+ if prefix, ok := ns.uriToPrefix[name.Space]; ok {
+ if prefix == "" {
+ return xml.Name{Local: name.Local}
+ }
+ return xml.Name{Local: prefix + ":" + name.Local}
+ }
+ return xml.Name{Local: name.Local}
+}
+
+// UpdateTags returns a copy of the provided XMP document with the TagsList updated to contain the
+// provided tags. When no source is supplied, a minimal XMP document is generated.
+func UpdateTags(source []byte, tags []string) ([]byte, error) {
+ if len(source) == 0 {
+ if len(tags) == 0 {
+ return nil, nil
+ }
+ return buildNewSidecar(tags)
+ }
+
+ updated, err := rewriteTags(source, tags)
+ if err != nil {
+ if len(tags) == 0 {
+ return source, nil
+ }
+ return buildNewSidecar(tags)
+ }
+ return updated, nil
+}
+
+func rewriteTags(source []byte, tags []string) ([]byte, error) {
+ decoder := xml.NewDecoder(bytes.NewReader(source))
+ buffer := &bytes.Buffer{}
+ encoder := xml.NewEncoder(buffer)
+ encoder.Indent("", " ")
+ ns := newNamespaceState()
+
+ var (
+ insideTagsList bool
+ nestedDepth int
+ tagsListFound bool
+ )
+
+ for {
+ token, err := decoder.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ switch tok := token.(type) {
+ case xml.StartElement:
+ if tok.Name.Space == namespaceDigiKam && tok.Name.Local == "TagsList" {
+ insideTagsList = true
+ nestedDepth = 0
+ tagsListFound = true
+ if err = ns.encodeStart(encoder, tok); err != nil {
+ return nil, err
+ }
+ continue
+ }
+ if insideTagsList {
+ ns.push(tok)
+ nestedDepth++
+ continue
+ }
+ if err = ns.encodeStart(encoder, tok); err != nil {
+ return nil, err
+ }
+ case xml.EndElement:
+ if insideTagsList {
+ if nestedDepth > 0 {
+ nestedDepth--
+ ns.pop()
+ continue
+ }
+ if err = writeSeq(encoder, ns, tags); err != nil {
+ return nil, err
+ }
+ if err = ns.encodeEnd(encoder, tok); err != nil {
+ return nil, err
+ }
+ insideTagsList = false
+ continue
+ }
+ if tok.Name.Space == namespaceRDF && tok.Name.Local == "RDF" && !tagsListFound && len(tags) > 0 {
+ if err = writeDescription(encoder, ns, tags); err != nil {
+ return nil, err
+ }
+ tagsListFound = true
+ }
+ if err = ns.encodeEnd(encoder, tok); err != nil {
+ return nil, err
+ }
+ case xml.CharData:
+ if insideTagsList {
+ continue
+ }
+ if err = encoder.EncodeToken(tok); err != nil {
+ return nil, err
+ }
+ case xml.Comment:
+ if insideTagsList {
+ continue
+ }
+ if err = encoder.EncodeToken(tok); err != nil {
+ return nil, err
+ }
+ case xml.Directive, xml.ProcInst:
+ if err = encoder.EncodeToken(tok); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ if insideTagsList {
+ return nil, errors.New("unterminated TagsList element")
+ }
+ if !tagsListFound && len(tags) > 0 {
+ return nil, errors.New("missing RDF Description to attach TagsList")
+ }
+
+ if err := encoder.Flush(); err != nil {
+ return nil, err
+ }
+
+ return buffer.Bytes(), nil
+}
+
+func buildNewSidecar(tags []string) ([]byte, error) {
+ buffer := &bytes.Buffer{}
+ _, _ = buffer.WriteString("\n")
+
+ encoder := xml.NewEncoder(buffer)
+ encoder.Indent("", " ")
+ ns := newNamespaceState()
+
+ xmpStart := xml.StartElement{
+ Name: xml.Name{Space: namespaceX, Local: "xmpmeta"},
+ Attr: []xml.Attr{
+ {Name: xml.Name{Space: "xmlns", Local: "x"}, Value: namespaceX},
+ },
+ }
+ if err := ns.encodeStart(encoder, xmpStart); err != nil {
+ return nil, err
+ }
+
+ rdfStart := xml.StartElement{
+ Name: xml.Name{Space: namespaceRDF, Local: "RDF"},
+ Attr: []xml.Attr{
+ {Name: xml.Name{Space: "xmlns", Local: "rdf"}, Value: namespaceRDF},
+ },
+ }
+ if err := ns.encodeStart(encoder, rdfStart); err != nil {
+ return nil, err
+ }
+
+ if err := writeDescription(encoder, ns, tags); err != nil {
+ return nil, err
+ }
+
+ if err := ns.encodeEnd(encoder, rdfStart.End()); err != nil {
+ return nil, err
+ }
+ if err := ns.encodeEnd(encoder, xmpStart.End()); err != nil {
+ return nil, err
+ }
+
+ if err := encoder.Flush(); err != nil {
+ return nil, err
+ }
+
+ _, _ = buffer.WriteString("\n")
+ return buffer.Bytes(), nil
+}
+
+func writeDescription(encoder *xml.Encoder, ns *namespaceState, tags []string) error {
+ descStart := xml.StartElement{
+ Name: xml.Name{Space: namespaceRDF, Local: "Description"},
+ Attr: []xml.Attr{
+ {Name: xml.Name{Space: namespaceRDF, Local: "about"}, Value: ""},
+ {Name: xml.Name{Space: "xmlns", Local: "digiKam"}, Value: namespaceDigiKam},
+ },
+ }
+ if err := ns.encodeStart(encoder, descStart); err != nil {
+ return err
+ }
+
+ tagsStart := xml.StartElement{Name: xml.Name{Space: namespaceDigiKam, Local: "TagsList"}}
+ if err := ns.encodeStart(encoder, tagsStart); err != nil {
+ return err
+ }
+
+ if err := writeSeq(encoder, ns, tags); err != nil {
+ return err
+ }
+
+ if err := ns.encodeEnd(encoder, tagsStart.End()); err != nil {
+ return err
+ }
+
+ return ns.encodeEnd(encoder, descStart.End())
+}
+
+func writeSeq(encoder *xml.Encoder, ns *namespaceState, tags []string) error {
+ seqStart := xml.StartElement{Name: xml.Name{Space: namespaceRDF, Local: "Seq"}}
+ if err := ns.encodeStart(encoder, seqStart); err != nil {
+ return err
+ }
+
+ for _, tag := range tags {
+ liStart := xml.StartElement{Name: xml.Name{Space: namespaceRDF, Local: "li"}}
+ if err := ns.encodeStart(encoder, liStart); err != nil {
+ return err
+ }
+ if err := encoder.EncodeToken(xml.CharData(tag)); err != nil {
+ return err
+ }
+ if err := ns.encodeEnd(encoder, liStart.End()); err != nil {
+ return err
+ }
+ }
+
+ return ns.encodeEnd(encoder, seqStart.End())
+}
diff --git a/internal/exif/sidecars/xmpsidecar/write_test.go b/internal/exif/sidecars/xmpsidecar/write_test.go
new file mode 100644
index 00000000..ef186e36
--- /dev/null
+++ b/internal/exif/sidecars/xmpsidecar/write_test.go
@@ -0,0 +1,123 @@
+package xmpsidecar
+
+import (
+ "bytes"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/simulot/immich-go/internal/assets"
+)
+
+func TestUpdateTagsWithExistingSidecar(t *testing.T) {
+ source, err := os.ReadFile("DATA/159d9172-2a1e-4d95-aef1-b5133549927b.jpg.xmp")
+ if err != nil {
+ t.Fatalf("read sample sidecar: %v", err)
+ }
+
+ result, err := UpdateTags(source, []string{"activities/outdoors", "Trips/Europe"})
+ if err != nil {
+ t.Fatalf("update tags: %v", err)
+ }
+ if len(result) == 0 {
+ t.Fatal("expected updated sidecar content")
+ }
+
+ md := &assets.Metadata{}
+ if err = ReadXMP(bytes.NewReader(result), md); err != nil {
+ t.Fatalf("parse updated sidecar: %v", err)
+ }
+
+ if len(md.Tags) != 2 {
+ t.Fatalf("expected 2 tags, got %d", len(md.Tags))
+ }
+ expected := map[string]struct{}{
+ "activities/outdoors": {},
+ "Trips/Europe": {},
+ }
+ for _, tag := range md.Tags {
+ if _, ok := expected[tag.Value]; !ok {
+ t.Fatalf("unexpected tag %q", tag.Value)
+ }
+ }
+ if md.Rating != 4 {
+ t.Fatalf("expected rating preserved, got %d", md.Rating)
+ }
+}
+
+func TestUpdateTagsWithoutSource(t *testing.T) {
+ result, err := UpdateTags(nil, []string{"New/Tag"})
+ if err != nil {
+ t.Fatalf("build new sidecar: %v", err)
+ }
+ if len(result) == 0 {
+ t.Fatal("expected new sidecar content")
+ }
+
+ md := &assets.Metadata{}
+ if err = ReadXMP(bytes.NewReader(result), md); err != nil {
+ t.Fatalf("parse generated sidecar: %v", err)
+ }
+ if len(md.Tags) != 1 || md.Tags[0].Value != "New/Tag" {
+ t.Fatalf("unexpected tags %#v", md.Tags)
+ }
+}
+
+func TestUpdateTagsPreservesNamespaces(t *testing.T) {
+ source, err := os.ReadFile("testdata/C0092.xmp")
+ if err != nil {
+ t.Fatalf("read sample sony sidecar: %v", err)
+ }
+
+ tags := []string{"volume/a6700", "{immich-go}/2025-10-13 00:07:30"}
+ result, err := UpdateTags(source, tags)
+ if err != nil {
+ t.Fatalf("update tags: %v", err)
+ }
+
+ if strings.Contains(string(result), "_xmlns") {
+ t.Fatalf("unexpected namespace mangling in output:\n%s", result)
+ }
+ if !strings.Contains(string(result), "\n \n \n volume/a6700\n {immich-go}/2025-10-13 00:07:30\n \n \n \n"
+ if !strings.Contains(resultStr, expectedBlock) {
+ t.Fatalf("expected DigiKam block in output, missing:\n%s", expectedBlock)
+ }
+ if strings.Count(resultStr, expectedBlock) != 1 {
+ t.Fatalf("expected DigiKam block exactly once")
+ }
+}