11package upload
22
33import (
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
496527func (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
550589func (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+
567714func (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 )
0 commit comments