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") + } +}