Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions adapters/folder/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,12 @@ func (ifc *ImportFolderCmd) parseDir(ctx context.Context, fsys fs.FS, dir string
}
}

// If no JSON but XMP exists, promote XMP metadata to FromApplication for upload
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this changes upload behavior, so if you like, it could be put behind a flag.

alternatively to preferring the json, could also merge the data from json and xmp for upload. could be nice for the case the xmp files were manually modified, but the json not updated/deleted.

// This ensures XMP sidecars created by immich-go archive command can be re-imported
if a.FromApplication == nil && a.FromSideCar != nil {
a.FromApplication = a.FromSideCar
}

// Read metadata from the file only id needed (date range or take date from filename)
if ifc.requiresDateInformation {
// try to get date from icloud takeout meta
Expand Down
84 changes: 61 additions & 23 deletions adapters/folder/writeFolder.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import (
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"

"github.com/simulot/immich-go/internal/assets"
"github.com/simulot/immich-go/internal/exif/sidecars"
"github.com/simulot/immich-go/internal/exif/sidecars/jsonsidecar"
"github.com/simulot/immich-go/internal/exif/sidecars/xmpsidecar"
"github.com/simulot/immich-go/internal/fshelper"
"github.com/simulot/immich-go/internal/fshelper/debugfiles"
)
Expand All @@ -24,17 +27,21 @@ type closer interface {
Close() error
}
type LocalAssetWriter struct {
WriteToFS fs.FS
createdDir map[string]struct{}
WriteToFS fs.FS
createdDir map[string]struct{}
SidecarFormat sidecars.SidecarFormat
Log *slog.Logger
}

func NewLocalAssetWriter(fsys fs.FS, writeToPath string) (*LocalAssetWriter, error) {
func NewLocalAssetWriter(fsys fs.FS, writeToPath string, format sidecars.SidecarFormat, log *slog.Logger) (*LocalAssetWriter, error) {
if _, ok := fsys.(fshelper.FSCanWrite); !ok {
return nil, errors.New("FS does not support writing")
}
return &LocalAssetWriter{
WriteToFS: fsys,
createdDir: make(map[string]struct{}),
WriteToFS: fsys,
createdDir: make(map[string]struct{}),
SidecarFormat: format,
Log: log,
}, nil
}

Expand Down Expand Up @@ -110,28 +117,59 @@ func (w *LocalAssetWriter) WriteAsset(ctx context.Context, a *assets.Asset) erro
if err != nil {
return err
}
// XMP?
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XMP!

if a.FromSideCar != nil {
// Sidecar file is set, copy it
var scr fs.File
scr, err = a.FromSideCar.File.Open()
if err != nil {
return err

// Warn about data loss when using XMP-only format
if w.SidecarFormat == sidecars.FormatXMP && a.FromApplication != nil {
var lostFields []string
if a.FromApplication.Trashed {
lostFields = append(lostFields, "Trashed")
}
debugfiles.TrackOpenFile(scr, a.FromSideCar.File.Name())
defer scr.Close()
defer debugfiles.TrackCloseFile(scr)
var scw fshelper.WFile
scw, err = fshelper.OpenFile(w.WriteToFS, path.Join(dir, base+".XMP"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
if a.FromApplication.Archived {
lostFields = append(lostFields, "Archived")
}
if a.FromApplication.FromPartner {
lostFields = append(lostFields, "FromPartner")
}
if len(lostFields) > 0 && w.Log != nil {
w.Log.Warn("XMP format cannot preserve some metadata",
"file", a.OriginalFileName,
"lost_fields", lostFields)
}
}

// Write XMP sidecar if format requires it
if w.SidecarFormat.CreatesXMP() {
if a.FromSideCar != nil && a.FromSideCar.File.FS() != nil {
// Existing XMP sidecar file - copy it
var scr fs.File
scr, err = a.FromSideCar.File.Open()
if err != nil {
return err
}
debugfiles.TrackOpenFile(scr, a.FromSideCar.File.Name())
defer scr.Close()
defer debugfiles.TrackCloseFile(scr)
var scw fshelper.WFile
scw, err = fshelper.OpenFile(w.WriteToFS, path.Join(dir, base+".xmp"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
_, err = io.Copy(scw, scr)
scw.Close()
} else if a.FromApplication != nil {
// Generate XMP from FromApplication metadata
var scw fshelper.WFile
scw, err = fshelper.OpenFile(w.WriteToFS, path.Join(dir, base+".xmp"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
err = xmpsidecar.Write(a.FromApplication, scw)
scw.Close()
}
_, err = io.Copy(scw, scr)
scw.Close()
}

// Having metadata from an Application or immich-go JSON?
if a.FromApplication != nil {
// Write JSON sidecar if format requires it
if w.SidecarFormat.CreatesJSON() && a.FromApplication != nil {
var scw fshelper.WFile
scw, err = fshelper.OpenFile(w.WriteToFS, path.Join(dir, base+".JSON"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
Expand Down
8 changes: 6 additions & 2 deletions app/archive/archiveCmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import (
gp "github.com/simulot/immich-go/adapters/googlePhotos"
"github.com/simulot/immich-go/app"
"github.com/simulot/immich-go/internal/assettracker"
"github.com/simulot/immich-go/internal/exif/sidecars"
"github.com/simulot/immich-go/internal/fileevent"
"github.com/simulot/immich-go/internal/fileprocessor"
"github.com/spf13/cobra"
)

type ArchiveCmd struct {
ArchivePath string
ArchivePath string
SidecarFormat sidecars.SidecarFormat

app *app.Application
dest *folder.LocalAssetWriter
Expand All @@ -27,11 +29,13 @@ func NewArchiveCommand(ctx context.Context, app *app.Application) *cobra.Command
Short: "Archive various sources of photos to a file system",
}
ac := &ArchiveCmd{
app: app,
app: app,
SidecarFormat: sidecars.FormatJSON, // Default to JSON for backward compatibility
}

cmd.PersistentFlags().StringVarP(&ac.ArchivePath, "write-to-folder", "w", "", "Path where to write the archive")
_ = cmd.MarkPersistentFlagRequired("write-to-folder")
cmd.PersistentFlags().Var(&ac.SidecarFormat, "sidecar-format", "Sidecar format: json (default), xmp, or both")

cmd.AddCommand(folder.NewFromFolderCommand(ctx, cmd, app, ac))
cmd.AddCommand(folder.NewFromICloudCommand(ctx, cmd, app, ac))
Expand Down
2 changes: 1 addition & 1 deletion app/archive/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func (ac *ArchiveCmd) Run(cmd *cobra.Command, adapter adapters.Reader) error {
}

destFS := osfs.DirFS(p)
ac.dest, err = folder.NewLocalAssetWriter(destFS, ".")
ac.dest, err = folder.NewLocalAssetWriter(destFS, ".", ac.SidecarFormat, log.Logger)
if err != nil {
return err
}
Expand Down
59 changes: 55 additions & 4 deletions docs/commands/archive.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ destination-folder/
├── 2022/
│ ├── 2022-01/
│ │ ├── photo01.jpg
│ │ └── photo01.jpg.JSON # Metadata file
│ │ ├── photo01.jpg.JSON # Metadata (with --sidecar-format json or both)
│ │ └── photo01.jpg.xmp # XMP sidecar (with --sidecar-format xmp or both)
│ └── 2022-02/
│ ├── photo02.jpg
│ └── photo02.jpg.JSON
│ ├── photo02.jpg.JSON
│ └── photo02.jpg.xmp
├── 2023/
│ ├── 2023-03/
│ └── 2023-04/
Expand All @@ -31,10 +33,44 @@ destination-folder/

## Required Options

| Option | Description |
|--------|-------------|
| Option | Description |
|---------------------|----------------------------------------|
| `--write-to-folder` | Destination folder for archived photos |

## Archive Options

| Option | Default | Description |
|--------------------|---------|------------------------------------------|
| `--sidecar-format` | `json` | Sidecar format: `json`, `xmp`, or `both` |

### Sidecar Format

The `--sidecar-format` option controls which metadata sidecar files are created:

| Format | Description |
|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `json` | Creates `.JSON` sidecars with full metadata. Best for round-trip with immich-go. |
| `xmp` | Creates `.xmp` sidecars using standard XMP format. Interoperable with other applications and supported by Immich in external libraries or on immich-cli upload. |
| `both` | Creates both `.JSON` and `.xmp` sidecars. Maximum compatibility. |

#### XMP Field Mapping

When using `xmp` or `both` format, the following fields are preserved:

| Metadata Field | XMP Path | Notes |
|----------------|-------------------------------------------|----------------------------------------|
| DateTaken | `exif:DateTimeOriginal` | ISO-8601 format |
| Description | `dc:description`, `tiff:ImageDescription` | Both namespaces for compatibility |
| Rating | `xmp:Rating` | 0-5 scale |
| Favorited | `xmp:Rating` | Stored as Rating=5 when Favorited=true |
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is debatable, and could also be hidden behind another flag

| Tags | `digiKam:TagsList` | Hierarchical paths |
| Albums | `digiKam:TagsList` | As `Albums/<AlbumName>` prefix |
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is debatable, and could also be hidden behind another flag

| GPS | `exif:GPSLatitude`, `exif:GPSLongitude` | DMS format |

**Fields NOT preserved in XMP** (JSON-only): `Trashed`, `Archived`, `FromPartner`, `FileName`
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is debatable, could also map to TagsList or something else


A warning is logged when using `--sidecar-format xmp` for assets with these non-preservable fields.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be annoying


## Sub-commands

All `upload` sub-commands are available for `archive`:
Expand Down Expand Up @@ -127,7 +163,22 @@ immich-go archive from-google-photos \
immich-go archive from-folder \
--write-to-folder=/organized \
/messy/photo/folders
```

### Archive with XMP Sidecars
```bash
# Create XMP sidecars for use with other applications
immich-go archive from-google-photos \
--sidecar-format=xmp \
--write-to-folder=/organized-photos \
/path/to/takeout-*.zip

# Create both JSON and XMP for maximum compatibility
immich-go archive from-immich \
--server=http://localhost:2283 \
--api-key=your-key \
--sidecar-format=both \
--write-to-folder=/backup
```

## Use Cases
Expand Down
50 changes: 50 additions & 0 deletions internal/exif/sidecars/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package sidecars

import (
"fmt"
"strings"
)

// SidecarFormat specifies which sidecar format(s) to create during archive operations
type SidecarFormat string

const (
// FormatJSON creates JSON sidecars only (default, supports later upload with immich-go without losing data like `Trashed`, `Archived`, `FromPartner`, `FileName`)
FormatJSON SidecarFormat = "json"
// FormatXMP creates XMP sidecars only (interoperable with other applications and supported by Immich in external libraries or on immich-cli upload)
FormatXMP SidecarFormat = "xmp"
// FormatBoth creates both JSON and XMP sidecars
FormatBoth SidecarFormat = "both"
)

// String returns the string representation of the format
func (f SidecarFormat) String() string {
return string(f)
}

// Set implements pflag.Value interface
func (f *SidecarFormat) Set(s string) error {
s = strings.ToLower(strings.TrimSpace(s))
switch s {
case "json", "xmp", "both":
*f = SidecarFormat(s)
return nil
default:
return fmt.Errorf("invalid sidecar format %q: must be json, xmp, or both", s)
}
}

// Type implements pflag.Value interface
func (f *SidecarFormat) Type() string {
return "string"
}

// CreatesJSON returns true if this format creates JSON sidecars
func (f SidecarFormat) CreatesJSON() bool {
return f == FormatJSON || f == FormatBoth
}

// CreatesXMP returns true if this format creates XMP sidecars
func (f SidecarFormat) CreatesXMP() bool {
return f == FormatXMP || f == FormatBoth
}
Loading