diff --git a/adapters/folder/readFolder.go b/adapters/folder/readFolder.go index 6bfb3830..5ced37b4 100644 --- a/adapters/folder/readFolder.go +++ b/adapters/folder/readFolder.go @@ -45,11 +45,13 @@ func NewLocalFiles(ctx context.Context, l *fileevent.Recorder, flags *ImportFold } la := LocalAssetBrowser{ - fsyss: fsyss, - flags: flags, - log: l, - pool: worker.NewPool(3), // TODO: Make this configurable - requiresDateInformation: flags.InclusionFlags.DateRange.IsSet() || flags.TakeDateFromFilename || flags.StackBurstPhotos, + fsyss: fsyss, + flags: flags, + log: l, + pool: worker.NewPool(3), // TODO: Make this configurable + requiresDateInformation: flags.InclusionFlags.DateRange.IsSet() || + flags.TakeDateFromFilename || flags.StackBurstPhotos || + flags.ManageHEICJPG != filters.HeicJpgNothing || flags.ManageRawJPG != filters.RawJPGNothing, } if flags.InfoCollector == nil { flags.InfoCollector = filenames.NewInfoCollector(flags.TZ, flags.SupportedMedia) diff --git a/app/client.go b/app/client.go index a47a48bf..bae03b39 100644 --- a/app/client.go +++ b/app/client.go @@ -18,7 +18,7 @@ import ( ) // add server flags to the command cmd -func AddClientFlags(ctx context.Context, cmd *cobra.Command, app *Application) { +func AddClientFlags(ctx context.Context, cmd *cobra.Command, app *Application, dryRun bool) { client := app.Client() client.DeviceUUID, _ = os.Hostname() @@ -28,7 +28,7 @@ func AddClientFlags(ctx context.Context, cmd *cobra.Command, app *Application) { cmd.PersistentFlags().BoolVar(&client.SkipSSL, "skip-verify-ssl", false, "Skip SSL verification") cmd.PersistentFlags().DurationVar(&client.ClientTimeout, "client-timeout", 5*time.Minute, "Set server calls timeout") cmd.PersistentFlags().StringVar(&client.DeviceUUID, "device-uuid", client.DeviceUUID, "Set a device UUID") - cmd.PersistentFlags().BoolVar(&client.DryRun, "dry-run", false, "Simulate all actions") + cmd.PersistentFlags().BoolVar(&client.DryRun, "dry-run", dryRun, "Simulate all actions") cmd.PersistentFlags().StringVar(&client.TimeZone, "time-zone", client.TimeZone, "Override the system time zone") cmd.PersistentPreRunE = ChainRunEFunctions(cmd.PersistentPreRunE, OpenClient, ctx, cmd, app) diff --git a/app/cmd/_todo/stack/stack.gono b/app/cmd/_todo/stack/stack.gono deleted file mode 100644 index b8ed1ab9..00000000 --- a/app/cmd/_todo/stack/stack.gono +++ /dev/null @@ -1,103 +0,0 @@ -package stack - -import ( - "fmt" - "sort" - "time" - - "github.com/simulot/immich-go/immich" - cliflags "github.com/simulot/immich-go/internal/cliFlags" - "github.com/simulot/immich-go/internal/stacking" - "github.com/simulot/immich-go/ui" - "github.com/spf13/cobra" -) - -type StackCmd struct { - Command *cobra.Command - *cmd.RootImmichFlags // global flags - *cmd.ImmichServerFlags // Immich server flags - AssumeYes bool - DateRange cliflags.DateRange // Set capture date range -} - -func AddCommand(root *cmd.RootImmichFlags) { - stackCmd := &cobra.Command{ - Use: "stack", - Short: "Stack photos", - Long: `Stack photos taken in the short period of time.`, - } - now := time.Now().Add(24 * time.Hour) - - ImmichServerFlags := cmd.AddImmichServerFlagSet(stackCmd, root) - - flags := &StackCmd{ - ImmichServerFlags: ImmichServerFlags, - DateRange: cliflags.DateRange{Before: time.Date(1980, 1, 1, 0, 0, 0, 0, time.Local), After: now}, - } - stackCmd.Flags().Var(&flags.DateRange, "date-range", "photos must be taken in the date range") - stackCmd.Flags().Bool("force-yes", false, "Assume YES to all questions") - root.Command.AddCommand(stackCmd) - - // TODO: call the run -} - -func (app *StackCmd) run(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - err := app.RootImmichFlags.Open(cmd) - if err != nil { - return err - } - err = app.ImmichServerFlags.Open(app.RootImmichFlags) - if err != nil { - return err - } - - sb := stacking.NewStackBuilder(app.Immich.SupportedMedia()) - fmt.Println("Get server's assets...") - assetCount := 0 - - err = app.Immich.GetAllAssetsWithFilter(ctx, func(a *immich.Asset) error { - if a.IsTrashed { - return nil - } - if !app.DateRange.InRange(a.ExifInfo.DateTimeOriginal.Time) { - return nil - } - assetCount += 1 - sb.ProcessAsset(a.ID, a.OriginalFileName, a.ExifInfo.DateTimeOriginal.Time) - return nil - }) - if err != nil { - return err - } - stacks := sb.Stacks() - app.Log.Info(fmt.Sprintf(" %d received, %d stack(s) possible\n", assetCount, len(stacks))) - - for _, s := range stacks { - fmt.Printf("Stack following images taken on %s\n", s.Date) - cover := s.CoverID - names := s.Names - sort.Strings(names) - for _, n := range names { - fmt.Printf(" %s\n", n) - } - yes := app.AssumeYes - if !app.AssumeYes { - r, err := ui.ConfirmYesNo(ctx, "Proceed?", "n") - if err != nil { - return err - } - if r == "y" { - yes = true - } - } - if yes { - err := app.Immich.StackAssets(ctx, cover, s.IDs) - if err != nil { - fmt.Printf("Can't stack images: %s\n", err) - } - } - } - - return nil -} diff --git a/app/cmd/commands.go b/app/cmd/commands.go index 2c9230d5..ff10e9e4 100644 --- a/app/cmd/commands.go +++ b/app/cmd/commands.go @@ -5,6 +5,7 @@ import ( "github.com/simulot/immich-go/app" "github.com/simulot/immich-go/app/cmd/archive" + "github.com/simulot/immich-go/app/cmd/stack" "github.com/simulot/immich-go/app/cmd/upload" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -27,10 +28,11 @@ func RootImmichGoCommand(ctx context.Context) (*cobra.Command, *app.Application) a := app.New(ctx, c) // add immich-go commands - c.AddCommand(app.NewVersionCommand(ctx, a)) c.AddCommand( + app.NewVersionCommand(ctx, a), upload.NewUploadCommand(ctx, a), archive.NewArchiveCommand(ctx, a), + stack.NewStackCommand(ctx, a), ) return c, a diff --git a/app/cmd/stack/stack.go b/app/cmd/stack/stack.go new file mode 100644 index 00000000..097d4673 --- /dev/null +++ b/app/cmd/stack/stack.go @@ -0,0 +1,219 @@ +package stack + +import ( + "context" + "sort" + "time" + + "github.com/simulot/immich-go/app" + "github.com/simulot/immich-go/immich" + "github.com/simulot/immich-go/internal/assets" + cliflags "github.com/simulot/immich-go/internal/cliFlags" + "github.com/simulot/immich-go/internal/filenames" + "github.com/simulot/immich-go/internal/filetypes" + "github.com/simulot/immich-go/internal/filters" + "github.com/simulot/immich-go/internal/groups" + "github.com/simulot/immich-go/internal/groups/burst" + "github.com/simulot/immich-go/internal/groups/epsonfastfoto" + "github.com/simulot/immich-go/internal/groups/series" + "github.com/spf13/cobra" +) + +/* +TODO +- [X] dry-run mode +- [X] existing stack --> apparently correctly handled by the server +- [X] Take sub second exif time into account +*/ +type StackCmd struct { + DateRange cliflags.DateRange // Set capture date range + + // Stack jpg/raw + StackJpgWithRaw bool + + // Stack burst + StackBurstPhotos bool + + // SupportedMedia is the server's actual list of supported media types. + SupportedMedia filetypes.SupportedMedia + + // InfoCollector is used to extract information from the file name. + InfoCollector *filenames.InfoCollector + + // ManageHEICJPG determines whether to manage HEIC to JPG conversion options. + ManageHEICJPG filters.HeicJpgFlag + + // ManageRawJPG determines how to manage raw and JPEG files. + ManageRawJPG filters.RawJPGFlag + + // BurstFlag determines how to manage burst photos. + ManageBurst filters.BurstFlag + + // ManageEpsonFastFoto enables the management of Epson FastFoto files. + ManageEpsonFastFoto bool + + TZ *time.Location + + assets []*assets.Asset + + groupers []groups.Grouper // groups are used to group assets + filters []filters.Filter // filters are used to filter assets in groups +} + +const timeFormat = "2006-01-02T15:04:05.000Z" + +func NewStackCommand(ctx context.Context, a *app.Application) *cobra.Command { + cmd := &cobra.Command{ + Use: "stack [flags]", + Short: "Update Immich for stacking related photos", + Long: `Stack photos related to each other according to the options`, + } + + o := &StackCmd{} + app.AddClientFlags(ctx, cmd, a, false) + cmd.TraverseChildren = true + cmd.Flags().Var(&o.ManageHEICJPG, "manage-heic-jpeg", "Manage coupled HEIC and JPEG files. Possible values: KeepHeic, KeepJPG, StackCoverHeic, StackCoverJPG") + cmd.Flags().Var(&o.ManageRawJPG, "manage-raw-jpeg", "Manage coupled RAW and JPEG files. Possible values: KeepRaw, KeepJPG, StackCoverRaw, StackCoverJPG") + cmd.Flags().Var(&o.ManageBurst, "manage-burst", "Manage burst photos. Possible values: Stack, StackKeepRaw, StackKeepJPEG") + cmd.Flags().BoolVar(&o.ManageEpsonFastFoto, "manage-epson-fastfoto", false, "Manage Epson FastFoto file (default: false)") + cmd.Flags().Var(&o.DateRange, "date-range", "photos must be taken in the date range") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { //nolint:contextcheck + // ready to run + ctx := cmd.Context() + client := a.Client() + o.TZ = a.GetTZ() + + o.InfoCollector = filenames.NewInfoCollector(o.TZ, client.Immich.SupportedMedia()) + o.filters = append(o.filters, + o.ManageBurst.GroupFilter(), + o.ManageRawJPG.GroupFilter(), + o.ManageHEICJPG.GroupFilter()) + + if o.ManageEpsonFastFoto { + o.groupers = append(o.groupers, epsonfastfoto.Group{}.Group) + } + if o.ManageBurst != filters.BurstNothing { + o.groupers = append(o.groupers, burst.Group) + } + o.groupers = append(o.groupers, series.Group) + + query := &immich.SearchMetadataQuery{ + WithExif: true, + } + + if o.DateRange.IsSet() { + query.TakenAfter = o.DateRange.After.Format(timeFormat) + query.TakenBefore = o.DateRange.Before.Format(timeFormat) + } + err := client.Immich.GetAllAssetsWithFilter(ctx, query, + func(a *immich.Asset) error { + if a.IsTrashed { + return nil + } + + asset := a.AsAsset() + asset.SetNameInfo(o.InfoCollector.GetInfo(asset.OriginalFileName)) + asset.FromApplication = &assets.Metadata{ + FileName: a.OriginalFileName, + Latitude: a.ExifInfo.Latitude, + Longitude: a.ExifInfo.Longitude, + Description: a.ExifInfo.Description, + DateTaken: a.ExifInfo.DateTimeOriginal.Time, + Trashed: a.IsTrashed, + Archived: a.IsArchived, + Favorited: a.IsFavorite, + Rating: byte(a.Rating), + Tags: asset.Tags, + } + + o.assets = append(o.assets, asset) + return nil + }) + if err != nil { + return err + } + err = o.ProcessAssets(ctx, a) + return err + } + return cmd +} + +func (s *StackCmd) ProcessAssets(ctx context.Context, app *app.Application) error { + log := app.Log() + + in := make(chan *assets.Asset) + + go func() { + defer close(in) + // Sort assets by radical, then date + sort.Slice(s.assets, func(i, j int) bool { + r1, r2 := s.assets[i].NameInfo.Radical, s.assets[j].NameInfo.Radical + if r1 != r2 { + return r1 < r2 + } + return s.assets[i].CaptureDate.Before(s.assets[j].CaptureDate) + }) + for _, a := range s.assets { + select { + case in <- a: + case <-ctx.Done(): + return + } + } + }() + + // Group assets + gChan := groups.NewGrouperPipeline(ctx, s.groupers...).PipeGrouper(ctx, in) + + for g := range gChan { + + g = filters.ApplyFilters(g, s.filters...) + + // Delete filtered assets + if len(g.Removed) > 0 { + for _, r := range g.Removed { + if err := app.Client().Immich.DeleteAssets(ctx, []string{r.Asset.ID}, false); err != nil { + log.Error("can't delete asset %s: %s", r.Asset.OriginalFileName, err) + } else { + log.Info("Asset %s deleted: %s", r.Asset.OriginalFileName, r.Reason) + } + } + } + + if len(g.Assets) > 1 && g.Grouping != assets.GroupByNone { + client := app.Client().Immich.(immich.ImmichStackInterface) + ids := []string{g.Assets[g.CoverIndex].ID} + for _, a := range g.Assets { + log.Info("Stacking", "file", a.OriginalFileName) + if a.ID != ids[0] { + ids = append(ids, a.ID) + } + } + if len(ids) > 1 { + if _, err := client.CreateStack(ctx, ids); err != nil { + log.Error("Can't create stack", "error", err) + } + } + } + } + return nil +} + +// gChan := make(chan *assets.Group) +// go func() { +// defer close(gChan) +// g := assets.NewGroup() +// for _, a := range s.assets { +// if !g.Add(a) { +// gChan <- g +// g = assets.NewGroup() +// g.Add(a) +// } +// } +// gChan <- g +// } +// gs := groups.NewGrouperPipeline(ctx, la.groupers...).PipeGrouper(ctx, in) +// g = filters.ApplyFilters(g, upCmd.UploadOptions.Filters...) + +// filters := append( []filters.Filter,) diff --git a/app/cmd/upload/noui.go b/app/cmd/upload/noui.go index f081cf33..80cb142b 100644 --- a/app/cmd/upload/noui.go +++ b/app/cmd/upload/noui.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "sync" "sync/atomic" "time" @@ -16,6 +17,7 @@ import ( func (upCmd *UpCmd) runNoUI(ctx context.Context, app *app.Application) error { ctx, cancel := context.WithCancelCause(ctx) + lock := sync.RWMutex{} defer cancel(nil) var preparationDone atomic.Bool @@ -26,7 +28,9 @@ func (upCmd *UpCmd) runNoUI(ctx context.Context, app *app.Application) error { spinIdx := 0 immichUpdate := func(value, total int) { + lock.Lock() currImmich, maxImmich = value, total + lock.Unlock() } progressString := func() string { @@ -37,12 +41,14 @@ func (upCmd *UpCmd) runNoUI(ctx context.Context, app *app.Application) error { spinIdx = 0 } }() + lock.Lock() immichPct := 0 if maxImmich > 0 { immichPct = 100 * currImmich / maxImmich } else { immichPct = 100 } + lock.Unlock() return fmt.Sprintf("\rImmich read %d%%, Assets found: %d, Upload errors: %d, Uploaded %d %s", immichPct, app.Jnl().TotalAssets(), counts[fileevent.UploadServerError], counts[fileevent.Uploaded], string(spinner[spinIdx])) } diff --git a/app/cmd/upload/upload.go b/app/cmd/upload/upload.go index 7ffab748..a8fc8c00 100644 --- a/app/cmd/upload/upload.go +++ b/app/cmd/upload/upload.go @@ -43,7 +43,7 @@ func NewUploadCommand(ctx context.Context, a *app.Application) *cobra.Command { Use: "upload", Short: "Upload photos to an Immich server from various sources", } - app.AddClientFlags(ctx, cmd, a) + app.AddClientFlags(ctx, cmd, a, false) cmd.TraverseChildren = true cmd.PersistentFlags().BoolVar(&options.NoUI, "no-ui", false, "Disable the user interface") cmd.PersistentPreRunE = app.ChainRunEFunctions(cmd.PersistentPreRunE, options.Open, ctx, cmd, a) diff --git a/immich/immich.go b/immich/immich.go index 430c9170..2f063f89 100644 --- a/immich/immich.go +++ b/immich/immich.go @@ -10,6 +10,7 @@ import ( "github.com/simulot/immich-go/internal/assets" "github.com/simulot/immich-go/internal/filetypes" + "github.com/simulot/immich-go/internal/fshelper" "github.com/simulot/immich-go/internal/tzone" ) @@ -209,6 +210,7 @@ func (ia Asset) AsAsset() *assets.Asset { Rating: ia.Rating, Latitude: ia.ExifInfo.Latitude, Longitude: ia.ExifInfo.Longitude, + File: fshelper.FSName(nil, ia.OriginalFileName), } a.FileSize = int(ia.ExifInfo.FileSizeInByte) for _, album := range ia.Albums { diff --git a/internal/e2eTests/stack/e2e_stack_test.go b/internal/e2eTests/stack/e2e_stack_test.go new file mode 100644 index 00000000..14a74f7d --- /dev/null +++ b/internal/e2eTests/stack/e2e_stack_test.go @@ -0,0 +1,141 @@ +package stack_test + +import ( + "context" + "fmt" + "os/exec" + "testing" + "time" + + "github.com/joho/godotenv" + "github.com/simulot/immich-go/app/cmd" + "github.com/simulot/immich-go/immich" +) + +func TestResetImmich(t *testing.T) { + initMyEnv(t) + reset_immich(t) +} + +func TestStackBurst(t *testing.T) { + ctx := context.Background() + + initMyEnv(t) + + reset_immich(t) + c, a := cmd.RootImmichGoCommand(ctx) + c.SetArgs([]string{ + "upload", "from-folder", + "--server=" + myEnv["IMMICHGO_SERVER"], + "--api-key=" + myEnv["IMMICHGO_APIKEY"], + "--no-ui", + "--dry-run=false", + "--manage-burst=Stack", + "--manage-heic-jpeg=StackCoverHeic", + "--manage-raw-jpeg=StackCoverRaw", + "--manage-epson-fastfoto=TRUE", + // "--api-trace", + // "--log-level=debug", + myEnv["IMMICHGO_TESTFILES"] + "/EpsonfastFoto/EpsonFastFoto.zip", + myEnv["IMMICHGO_TESTFILES"] + "/burst/Reflex", + myEnv["IMMICHGO_TESTFILES"] + "/burst/PXL6", + myEnv["IMMICHGO_TESTFILES"] + "/burst/Tel", + + myEnv["IMMICHGO_TESTFILES"] + "/burst/storm", + // myEnv["IMMICHGO_TESTFILES"] + "/burst/storm full", + }) + + err := c.ExecuteContext(ctx) + if err != nil && a.Log().GetSLog() != nil { + a.Log().Error(err.Error()) + t.Fatal(err) + } + + client, err := immich.NewImmichClient( + myEnv["IMMICHGO_SERVER"], + myEnv["IMMICHGO_APIKEY"], + ) + ctx2, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) +check: + for { + select { + case <-ctx2.Done(): + t.Fatal("Timeout waiting for metadata job to terminate") + default: + jobs, err := client.GetJobs(ctx2) + if err != nil { + t.Fatal(err) + } + if jobs["metadataExtraction"].JobCounts.Active == 0 { + cancel() + break check + } + fmt.Println("Waiting for metadata extraction to finish") + time.Sleep(1 * time.Second) + } + } + + c, a = cmd.RootImmichGoCommand(ctx) + c.SetArgs([]string{ + "stack", + "--server=" + myEnv["IMMICHGO_SERVER"], + "--api-key=" + myEnv["IMMICHGO_APIKEY"], + "--api-trace", + "--log-level=debug", + // "--dry-run=false", + "--manage-burst=Stack", + "--manage-heic-jpeg=StackCoverHeic", + "--manage-raw-jpeg=StackCoverRaw", + "--manage-epson-fastfoto=TRUE", + }) + err = c.ExecuteContext(ctx) + if err != nil && a.Log().GetSLog() != nil { + a.Log().Error(err.Error()) + t.Fatal(err) + } +} + +var myEnv map[string]string + +func initMyEnv(t *testing.T) { + if len(myEnv) > 0 { + return + } + var err error + e, err := godotenv.Read("../../../e2e.env") + if err != nil { + t.Fatalf("cant initialize environment variables: %s", err) + } + myEnv = e + if myEnv["IMMICHGO_TESTFILES"] == "" { + t.Fatal("missing IMMICHGO_TESTFILES in .env file") + } +} + +func reset_immich(t *testing.T) { + // Reset immich's database + // https://github.com/immich-app/immich/blob/main/e2e/src/utils.ts + // + c := exec.Command("docker", "exec", "-i", "immich_postgres", "psql", "--dbname=immich", "--username=postgres", "-c", + ` + DELETE FROM asset_stack CASCADE; + DELETE FROM libraries CASCADE; + DELETE FROM shared_links CASCADE; + DELETE FROM person CASCADE; + DELETE FROM albums CASCADE; + DELETE FROM assets CASCADE; + DELETE FROM asset_faces CASCADE; + DELETE FROM activity CASCADE; + --DELETE FROM api_keys CASCADE; + --DELETE FROM sessions CASCADE; + --DELETE FROM users CASCADE; + DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags'); + DELETE FROM tags CASCADE; + `, + ) + b, err := c.CombinedOutput() + if err != nil { + t.Log(string(b)) + t.Fatal(err) + } +} diff --git a/internal/exif/direct.go b/internal/exif/direct.go index c50ad4e9..73852ad3 100644 --- a/internal/exif/direct.go +++ b/internal/exif/direct.go @@ -144,18 +144,11 @@ func getExifMetadata(x *exif.Exif, local *time.Location) (*assets.Metadata, erro // md.DateTaken, err = readGPSTimeStamp(x, local) // if err != nil || md.DateTaken.IsZero() { // GPS Time Stamp is not reliable - var tag string - tag, err = getTagSting(x, exif.DateTimeOriginal) - if err == nil { - md.DateTaken, err = time.ParseInLocation("2006:01:02 15:04:05", tag, local) - } + + md.DateTaken, err = readDateTime(x, exif.DateTimeOriginal, exif.SubSecTimeOriginal, local) if err != nil { - tag, err = getTagSting(x, exif.DateTime) - if err == nil { - md.DateTaken, _ = time.ParseInLocation("2006:01:02 15:04:05", tag, local) // last chance - } + md.DateTaken, err = readDateTime(x, exif.DateTime, exif.SubSecTime, local) } - if err == nil { lat, lon, err := x.LatLong() if err == nil { @@ -166,6 +159,21 @@ func getExifMetadata(x *exif.Exif, local *time.Location) (*assets.Metadata, erro return md, err } +// readDateTime with subsecond when possible +func readDateTime(x *exif.Exif, dateTag exif.FieldName, subSecTag exif.FieldName, local *time.Location) (time.Time, error) { + date, err := getTagSting(x, dateTag) + if err != nil { + return time.Time{}, err + } + subSec, err := getTagSting(x, subSecTag) + if err == nil { + subSec += "000" + date = date + "." + subSec[:3] + return time.ParseInLocation("2006:01:02 15:04:05.000", date, local) + } + return time.ParseInLocation("2006:01:02 15:04:05", date, local) +} + /* // readGPSTimeStamp extract the date from the GPS data diff --git a/readme.md b/readme.md index 6f3781d5..6f1237e4 100644 --- a/readme.md +++ b/readme.md @@ -173,6 +173,7 @@ Commands must be combined with sub-commands and options to perform the required * from-folder * from-google-photos * from-immich + * [stack](#the-stack-command) * version Examples: @@ -449,6 +450,24 @@ The sub-command **from-immich** processes an Immich server to upload photos to a | --from-skip-verify-ssl | `FALSE` | Skip SSL verification | +# The **stack** command: +The stack command open the immich server, for the user associated with the the API-KEY, and stacks related photos together. The command accepts the following options: + +| **Parameter** | **Default value** | **Description** | +| ----------------------- | :---------------: | --------------------------------------------------------------------------------------------------------- | +| -s, --server | | Immich server address (example http://your-ip:2283 or https://your-domain) (**MANDATORY**) | +| -k, --api-key | | API Key (**MANDATORY**) | +| --api-trace | `FALSE` | Enable trace of api calls | +| --client-timeout | `5m0s` | Set server calls timeout | +| --dry-run | | Simulate all server actions... | +| --skip-verify-ssl | `FALSE` | Skip SSL verification | +| --time-zone | | Override the system time zone (example: Europe/Paris) | +| --manage-burst | | Manage burst photos. Possible values: Stack, StackKeepRaw, StackKeepJPEG. [See option's details](#burst-detection-and-management) | +| --manage-epson-fastfoto | `FALSE` | Manage Epson FastFoto file (default: false) | +| --manage-heic-jpeg | | Manage coupled HEIC and JPEG files. Possible values: KeepHeic, KeepJPG, StackCoverHeic, StackCoverJPG [See option's details](#management-of-coupled-heic-and-jpeg-files) | +| --manage-raw-jpeg | | Manage coupled RAW and JPEG files. Possible values: KeepRaw, KeepJPG, StackCoverRaw, StackCoverJPG. [See options's details](#management-of-coupled-raw-and-jpeg-files) | + + # Additional information and best practices ## **XMP** files process