-
-
Notifications
You must be signed in to change notification settings - Fork 186
add --sidecar-format flag for XMP sidecar generation #1274
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
| ) | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
|
@@ -110,28 +117,59 @@ func (w *LocalAssetWriter) WriteAsset(ctx context.Context, a *assets.Asset) erro | |
| if err != nil { | ||
| return err | ||
| } | ||
| // XMP? | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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/ | ||
|
|
@@ -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 | | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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` | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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`: | ||
|
|
@@ -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 | ||
|
|
||
| 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 | ||
| } |
There was a problem hiding this comment.
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.