diff --git a/browser/files/localassets_test.go b/browser/files/localassets_test.go index 1b821d1a..da17887c 100644 --- a/browser/files/localassets_test.go +++ b/browser/files/localassets_test.go @@ -74,7 +74,7 @@ func TestLocalAssets(t *testing.T) { } ctx := context.Background() - b, err := files.NewLocalFiles(ctx, logger.NewJournal(logger.NoLogger{}), fsys) + b, err := files.NewLocalFiles(ctx, logger.NewJournal(logger.NoLog{}), fsys) if err != nil { t.Error(err) } diff --git a/browser/gp/googlephotos.go b/browser/gp/googlephotos.go index f33f06e6..6bf1e72a 100644 --- a/browser/gp/googlephotos.go +++ b/browser/gp/googlephotos.go @@ -228,7 +228,7 @@ var matchers = []matcherFn{ // func (to *Takeout) solvePuzzle() { - to.jnl.OK("Associating JSON and assets...") + to.jnl.Log.OK("Associating JSON and assets...") jsonKeys := gen.MapKeys(to.jsonByYear) sort.Slice(jsonKeys, func(i, j int) bool { yd := jsonKeys[i].year - jsonKeys[j].year @@ -417,7 +417,7 @@ func (to *Takeout) Browse(ctx context.Context) chan *browser.LocalAssetFile { } func (to *Takeout) passTwoWalk(ctx context.Context, w fs.FS, assetChan chan *browser.LocalAssetFile) error { - to.jnl.OK("Ready to upload files") + to.jnl.Log.OK("Ready to upload files") return fs.WalkDir(w, ".", func(name string, d fs.DirEntry, err error) error { if err != nil { return nil @@ -449,7 +449,7 @@ func (to *Takeout) passTwoWalk(ctx context.Context, w fs.FS, assetChan chan *bro } finfo, err := d.Info() if err != nil { - to.jnl.Error("can't browse: %s", err) + to.jnl.Log.Error("can't browse: %s", err) return nil } diff --git a/browser/gp/testgp_bigread_test.go b/browser/gp/testgp_bigread_test.go index 96c95ed9..dd2564c0 100644 --- a/browser/gp/testgp_bigread_test.go +++ b/browser/gp/testgp_bigread_test.go @@ -19,7 +19,9 @@ func TestReadBigTakeout(t *testing.T) { panic(err) } - j := logger.NewJournal(logger.NewLogger(logger.Info, true, false).SetWriter(f)) + l := logger.NewLogger(logger.Info, true, false) + l.SetWriter(f) + j := logger.NewJournal(l) m, err := filepath.Glob("../../../test-data/full_takeout/*.zip") if err != nil { t.Error(err) diff --git a/browser/gp/testgp_test.go b/browser/gp/testgp_test.go index 8c6bcb8c..ea76f0f2 100644 --- a/browser/gp/testgp_test.go +++ b/browser/gp/testgp_test.go @@ -112,7 +112,7 @@ func TestBrowse(t *testing.T) { } ctx := context.Background() - b, err := NewTakeout(ctx, logger.NewJournal(logger.NoLogger{}), fsys) + b, err := NewTakeout(ctx, logger.NewJournal(logger.NoLog{}), fsys) if err != nil { t.Error(err) } @@ -184,7 +184,7 @@ func TestAlbums(t *testing.T) { t.Error(fsys.err) return } - b, err := NewTakeout(ctx, logger.NewJournal(logger.NoLogger{}), fsys) + b, err := NewTakeout(ctx, logger.NewJournal(logger.NoLog{}), fsys) if err != nil { t.Error(err) } diff --git a/cmd/album/album.go b/cmd/album/album.go index 84888507..5affb9cf 100644 --- a/cmd/album/album.go +++ b/cmd/album/album.go @@ -8,36 +8,36 @@ import ( "sort" "strconv" - "github.com/simulot/immich-go/immich" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/logger" "github.com/simulot/immich-go/ui" ) -func AlbumCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, args []string) error { +func AlbumCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { if len(args) > 0 { cmd := args[0] args = args[1:] if cmd == "delete" { - return deleteAlbum(ctx, ic, log, args) + return deleteAlbum(ctx, common, args) } } return fmt.Errorf("tool album need a command: delete") } type DeleteAlbumCmd struct { - log *logger.Log - Immich *immich.ImmichClient // Immich client - pattern *regexp.Regexp // album pattern + *cmd.SharedFlags + pattern *regexp.Regexp // album pattern AssumeYes bool } -func deleteAlbum(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, args []string) error { +func deleteAlbum(ctx context.Context, common *cmd.SharedFlags, args []string) error { app := &DeleteAlbumCmd{ - log: log, - Immich: ic, + SharedFlags: common, } cmd := flag.NewFlagSet("album delete", flag.ExitOnError) + app.SharedFlags.SetFlags(cmd) + cmd.BoolFunc("yes", "When true, assume Yes to all actions", func(s string) error { var err error app.AssumeYes, err = strconv.ParseBool(s) @@ -47,7 +47,10 @@ func deleteAlbum(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, if err != nil { return err } - + err = app.SharedFlags.Start(ctx) + if err != nil { + return err + } args = cmd.Args() if len(args) > 0 { re, err := regexp.Compile(args[0]) @@ -59,7 +62,7 @@ func deleteAlbum(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, app.pattern = regexp.MustCompile(`.*`) } - albums, err := ic.GetAllAlbums(ctx) + albums, err := app.Immich.GetAllAlbums(ctx) if err != nil { return fmt.Errorf("can't get the albums list: %w", err) } @@ -71,7 +74,7 @@ func deleteAlbum(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, if app.pattern.MatchString(al.AlbumName) { yes := app.AssumeYes if !yes { - app.log.OK("Delete album '%s'?", al.AlbumName) + app.Jnl.Log.OK("Delete album '%s'?", al.AlbumName) r, err := ui.ConfirmYesNo(ctx, "Proceed?", "n") if err != nil { return err @@ -81,12 +84,12 @@ func deleteAlbum(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, } } if yes { - app.log.MessageContinue(logger.OK, "Deleting album '%s'", al.AlbumName) + app.Jnl.Log.MessageContinue(logger.OK, "Deleting album '%s'", al.AlbumName) err = app.Immich.DeleteAlbum(ctx, al.ID) if err != nil { return err } else { - app.log.MessageTerminate(logger.OK, "done") + app.Jnl.Log.MessageTerminate(logger.OK, "done") } } } diff --git a/cmd/duplicate/duplicate.go b/cmd/duplicate/duplicate.go index 7cc292be..118a398c 100644 --- a/cmd/duplicate/duplicate.go +++ b/cmd/duplicate/duplicate.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/helpers/gen" "github.com/simulot/immich-go/helpers/myflag" "github.com/simulot/immich-go/immich" @@ -19,9 +20,7 @@ import ( ) type DuplicateCmd struct { - logger *logger.Log - Immich *immich.ImmichClient // Immich client - + *cmd.SharedFlags AssumeYes bool // When true, doesn't ask to the user DateRange immich.DateRange // Set capture date range IgnoreTZErrors bool // Enable TZ error tolerance @@ -35,33 +34,41 @@ type duplicateKey struct { Name string } -func NewDuplicateCmd(ctx context.Context, ic *immich.ImmichClient, logger *logger.Log, args []string) (*DuplicateCmd, error) { +func NewDuplicateCmd(ctx context.Context, common *cmd.SharedFlags, args []string) (*DuplicateCmd, error) { cmd := flag.NewFlagSet("duplicate", flag.ExitOnError) validRange := immich.DateRange{} _ = validRange.Set("1850-01-04,2030-01-01") app := DuplicateCmd{ - logger: logger, - Immich: ic, + SharedFlags: common, DateRange: validRange, assetsByID: map[string]*immich.Asset{}, assetsByBaseAndDate: map[duplicateKey][]*immich.Asset{}, } + app.SharedFlags.SetFlags(cmd) + cmd.BoolFunc("ignore-tz-errors", "Ignore timezone difference to check duplicates (default: FALSE).", myflag.BoolFlagFn(&app.IgnoreTZErrors, false)) cmd.BoolFunc("yes", "When true, assume Yes to all actions", myflag.BoolFlagFn(&app.AssumeYes, false)) cmd.Var(&app.DateRange, "date", "Process only documents having a capture date in that range.") err := cmd.Parse(args) + if err != nil { + return nil, err + } + err = app.SharedFlags.Start(ctx) + if err != nil { + return nil, err + } return &app, err } -func DuplicateCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, args []string) error { - app, err := NewDuplicateCmd(ctx, ic, log, args) +func DuplicateCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { + app, err := NewDuplicateCmd(ctx, common, args) if err != nil { return err } dupCount := 0 - log.MessageContinue(logger.OK, "Get server's assets...") + app.Jnl.Log.MessageContinue(logger.OK, "Get server's assets...") err = app.Immich.GetAllAssetsWithFilter(ctx, nil, func(a *immich.Asset) { if a.IsTrashed { return @@ -88,8 +95,8 @@ func DuplicateCommand(ctx context.Context, ic *immich.ImmichClient, log *logger. if err != nil { return err } - log.MessageTerminate(logger.OK, "%d received", len(app.assetsByID)) - log.MessageTerminate(logger.OK, "%d duplicate(s) determined.", dupCount) + app.Jnl.Log.MessageTerminate(logger.OK, "%d received", len(app.assetsByID)) + app.Jnl.Log.MessageTerminate(logger.OK, "%d duplicate(s) determined.", dupCount) keys := gen.MapFilterKeys(app.assetsByBaseAndDate, func(i []*immich.Asset) bool { return len(i) > 1 @@ -113,22 +120,22 @@ func DuplicateCommand(ctx context.Context, ic *immich.ImmichClient, log *logger. return ctx.Err() default: l := app.assetsByBaseAndDate[k] - app.logger.OK("There are %d copies of the asset %s, taken on %s ", len(l), k.Name, l[0].ExifInfo.DateTimeOriginal.Format(time.RFC3339)) + app.Jnl.Log.OK("There are %d copies of the asset %s, taken on %s ", len(l), k.Name, l[0].ExifInfo.DateTimeOriginal.Format(time.RFC3339)) albums := []immich.AlbumSimplified{} assetsToDelete := []string{} sort.Slice(l, func(i, j int) bool { return l[i].ExifInfo.FileSizeInByte < l[j].ExifInfo.FileSizeInByte }) for p, a := range l { if p < len(l)-1 { - log.OK(" delete %s %dx%d, %s, %s", a.OriginalFileName, a.ExifInfo.ExifImageWidth, a.ExifInfo.ExifImageHeight, ui.FormatBytes(a.ExifInfo.FileSizeInByte), a.OriginalPath) + app.Jnl.Log.OK(" delete %s %dx%d, %s, %s", a.OriginalFileName, a.ExifInfo.ExifImageWidth, a.ExifInfo.ExifImageHeight, ui.FormatBytes(a.ExifInfo.FileSizeInByte), a.OriginalPath) assetsToDelete = append(assetsToDelete, a.ID) r, err := app.Immich.GetAssetAlbums(ctx, a.ID) if err != nil { - log.Error("Can't get asset's albums: %s", err.Error()) + app.Jnl.Log.Error("Can't get asset's albums: %s", err.Error()) } else { albums = append(albums, r...) } } else { - log.OK(" keep %s %dx%d, %s, %s", a.OriginalFileName, a.ExifInfo.ExifImageWidth, a.ExifInfo.ExifImageHeight, ui.FormatBytes(a.ExifInfo.FileSizeInByte), a.OriginalPath) + app.Jnl.Log.OK(" keep %s %dx%d, %s, %s", a.OriginalFileName, a.ExifInfo.ExifImageWidth, a.ExifInfo.ExifImageHeight, ui.FormatBytes(a.ExifInfo.FileSizeInByte), a.OriginalPath) yes := app.AssumeYes if !app.AssumeYes { r, err := ui.ConfirmYesNo(ctx, "Proceed?", "n") @@ -142,14 +149,14 @@ func DuplicateCommand(ctx context.Context, ic *immich.ImmichClient, log *logger. if yes { err = app.Immich.DeleteAssets(ctx, assetsToDelete, false) if err != nil { - log.Error("Can't delete asset: %s", err.Error()) + app.Jnl.Log.Error("Can't delete asset: %s", err.Error()) } else { - log.OK(" Asset removed") + app.Jnl.Log.OK(" Asset removed") for _, al := range albums { - log.OK(" Update the album %s with the best copy", al.AlbumName) + app.Jnl.Log.OK(" Update the album %s with the best copy", al.AlbumName) _, err = app.Immich.AddAssetToAlbum(ctx, al.ID, []string{a.ID}) if err != nil { - log.Error("Can't delete asset: %s", err.Error()) + app.Jnl.Log.Error("Can't delete asset: %s", err.Error()) } } } diff --git a/cmd/metadata/metadata.go b/cmd/metadata/metadata.go index 371e7195..274427fc 100644 --- a/cmd/metadata/metadata.go +++ b/cmd/metadata/metadata.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/helpers/docker" "github.com/simulot/immich-go/helpers/myflag" "github.com/simulot/immich-go/immich" @@ -16,32 +17,35 @@ import ( ) type MetadataCmd struct { - Immich *immich.ImmichClient // Immich client - Log *logger.Log + *cmd.SharedFlags DryRun bool MissingDateDespiteName bool MissingDate bool DockerHost string } -func NewMetadataCmd(ctx context.Context, ic *immich.ImmichClient, logger *logger.Log, args []string) (*MetadataCmd, error) { +func NewMetadataCmd(ctx context.Context, common *cmd.SharedFlags, args []string) (*MetadataCmd, error) { var err error cmd := flag.NewFlagSet("metadata", flag.ExitOnError) app := MetadataCmd{ - Immich: ic, - Log: logger, + SharedFlags: common, } + app.SharedFlags.SetFlags(cmd) cmd.BoolFunc("dry-run", "display actions, but don't touch the server assets", myflag.BoolFlagFn(&app.DryRun, false)) cmd.BoolFunc("missing-date", "select all assets where the date is missing", myflag.BoolFlagFn(&app.MissingDate, false)) cmd.BoolFunc("missing-date-with-name", "select all assets where the date is missing but the name contains a the date", myflag.BoolFlagFn(&app.MissingDateDespiteName, false)) cmd.StringVar(&app.DockerHost, "docker-host", "local", "Immich's docker host where to inject sidecar file as workaround for the issue #3888. 'local' for local connection, 'ssh://user:password@server' for remote host.") err = cmd.Parse(args) + if err != nil { + return nil, err + } + err = app.SharedFlags.Start(ctx) return &app, err } -func MetadataCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, args []string) error { - app, err := NewMetadataCmd(ctx, ic, log, args) +func MetadataCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { + app, err := NewMetadataCmd(ctx, common, args) if err != nil { return err } @@ -53,15 +57,15 @@ func MetadataCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.L if err != nil { return err } - app.Log.OK("Connected to the immich's docker container at %q", app.DockerHost) + app.Jnl.Log.OK("Connected to the immich's docker container at %q", app.DockerHost) } - app.Log.MessageContinue(logger.OK, "Get server's assets...") + app.Jnl.Log.MessageContinue(logger.OK, "Get server's assets...") list, err := app.Immich.GetAllAssets(ctx, nil) if err != nil { return err } - app.Log.MessageTerminate(logger.OK, " %d received", len(list)) + app.Jnl.Log.MessageTerminate(logger.OK, " %d received", len(list)) type broken struct { a *immich.Asset @@ -110,18 +114,18 @@ func MetadataCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.L if b.fixable { fixable++ } - app.Log.OK("%s, (%s %s): %s", b.a.OriginalPath, b.a.ExifInfo.Make, b.a.ExifInfo.Model, strings.Join(b.reason, ", ")) + app.Jnl.Log.OK("%s, (%s %s): %s", b.a.OriginalPath, b.a.ExifInfo.Make, b.a.ExifInfo.Model, strings.Join(b.reason, ", ")) } - app.Log.OK("%d broken assets", len(brockenAssets)) - app.Log.OK("Among them, %d can be fixed with current settings", fixable) + app.Jnl.Log.OK("%d broken assets", len(brockenAssets)) + app.Jnl.Log.OK("Among them, %d can be fixed with current settings", fixable) if fixable == 0 { return nil } if app.DryRun { - log.OK("Dry-run mode. Exiting") - log.OK("use -dry-run=false after metadata command") + app.Jnl.Log.OK("Dry-run mode. Exiting") + app.Jnl.Log.OK("use -dry-run=false after metadata command") return nil } @@ -137,7 +141,7 @@ func MetadataCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.L continue } a := b.a - app.Log.MessageContinue(logger.OK, "Uploading sidecar for %s... ", a.OriginalPath) + app.Jnl.Log.MessageContinue(logger.OK, "Uploading sidecar for %s... ", a.OriginalPath) scContent, err := b.SideCar.Bytes() if err != nil { return err @@ -146,7 +150,7 @@ func MetadataCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.L if err != nil { return err } - app.Log.MessageTerminate(logger.OK, "done") + app.Jnl.Log.MessageTerminate(logger.OK, "done") } return nil } diff --git a/cmd/shared.go b/cmd/shared.go new file mode 100644 index 00000000..b54d480f --- /dev/null +++ b/cmd/shared.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "context" + "errors" + "flag" + "io" + "os" + "runtime" + "strings" + + "github.com/simulot/immich-go/helpers/myflag" + "github.com/simulot/immich-go/helpers/tzone" + "github.com/simulot/immich-go/immich" + "github.com/simulot/immich-go/logger" +) + +// SharedFlags collect all parameters that are common to all commands +type SharedFlags struct { + Server string // Immich server address (http://:2283/api or https:///api) + API string // Immich api endpoint (http://container_ip:3301) + Key string // API Key + DeviceUUID string // Set a device UUID + APITrace bool // Enable API call traces + NoLogColors bool // Disable log colors + LogLevel string // Indicate the log level + Debug bool // Enable the debug mode + TimeZone string // Override default TZ + SkipSSL bool // Skip SSL Verification + + Immich immich.ImmichInterface // Immich client + Jnl *logger.Journal // Program's logger + LogFile string // Log file + out io.WriteCloser // the log writer +} + +// SetFlag add common flags to a flagset +func (app *SharedFlags) SetFlags(fs *flag.FlagSet) { + fs.StringVar(&app.Server, "server", app.Server, "Immich server address (http://:2283 or https://)") + fs.StringVar(&app.API, "api", "", "Immich api endpoint (http://container_ip:3301)") + fs.StringVar(&app.Key, "key", app.Key, "API Key") + fs.StringVar(&app.DeviceUUID, "device-uuid", app.DeviceUUID, "Set a device UUID") + fs.BoolFunc("no-colors-log", "Disable colors on logs", myflag.BoolFlagFn(&app.NoLogColors, runtime.GOOS == "windows")) + fs.StringVar(&app.LogLevel, "log-level", app.LogLevel, "Log level (Error|Warning|OK|Info), default OK") + fs.StringVar(&app.LogFile, "log-file", app.LogFile, "Write log messages into the file") + fs.BoolFunc("api-trace", "enable api call traces", myflag.BoolFlagFn(&app.APITrace, false)) + fs.BoolFunc("debug", "enable debug messages", myflag.BoolFlagFn(&app.Debug, false)) + fs.StringVar(&app.TimeZone, "time-zone", app.TimeZone, "Override the system time zone") + fs.BoolFunc("skip-verify-ssl", "Skip SSL verification", myflag.BoolFlagFn(&app.SkipSSL, false)) +} + +func (app *SharedFlags) Start(ctx context.Context) error { + var joinedErr, err error + if app.Server != "" { + app.Server = strings.TrimSuffix(app.Server, "/") + } + if app.TimeZone != "" { + _, err := tzone.SetLocal(app.TimeZone) + joinedErr = errors.Join(joinedErr, err) + } + + if app.Jnl == nil { + app.Jnl = logger.NewJournal(logger.NewLogger(logger.OK, true, false)) + } + + if app.LogFile != "" { + if app.out == nil { + f, err := os.Create(app.LogFile) + if err != nil { + joinedErr = errors.Join(joinedErr, err) + } else { + app.Jnl.Log.SetWriter(f) + } + app.out = f + } + } + + if app.LogLevel != "" { + logLevel, err := logger.StringToLevel(app.LogLevel) + if err != nil { + joinedErr = errors.Join(joinedErr, err) + } + app.Jnl.Log.SetLevel(logLevel) + } + + app.Jnl.Log.SetColors(!app.NoLogColors) + app.Jnl.Log.SetDebugFlag(app.Debug) + + // at this point, exits if there is an error + if joinedErr != nil { + return joinedErr + } + + // If the client isn't yet initialized + if app.Immich == nil { + switch { + case app.Server == "" && app.API == "": + joinedErr = errors.Join(joinedErr, errors.New("missing -server, Immich server address (http://:2283 or https://)")) + case app.Server != "" && app.API != "": + joinedErr = errors.Join(joinedErr, errors.New("give either the -server or the -api option")) + } + if app.Key == "" { + joinedErr = errors.Join(joinedErr, errors.New("missing -key")) + return joinedErr + } + + app.Immich, err = immich.NewImmichClient(app.Server, app.Key, app.SkipSSL) + if err != nil { + return err + } + if app.API != "" { + app.Immich.SetEndPoint(app.API) + } + if app.APITrace { + app.Immich.EnableAppTrace(true) + } + if app.DeviceUUID != "" { + app.Immich.SetDeviceUUID(app.DeviceUUID) + } + + err = app.Immich.PingServer(ctx) + if err != nil { + return err + } + app.Jnl.Log.OK("Server status: OK") + + user, err := app.Immich.ValidateConnection(ctx) + if err != nil { + return err + } + app.Jnl.Log.Info("Connected, user: %s", user.Email) + } + return nil +} diff --git a/cmd/stack/stack.go b/cmd/stack/stack.go index bda7af01..747c18b2 100644 --- a/cmd/stack/stack.go +++ b/cmd/stack/stack.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/helpers/stacking" "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/logger" @@ -14,24 +15,21 @@ import ( ) type StackCmd struct { - Immich *immich.ImmichClient // Immich client - logger *logger.Log - + *cmd.SharedFlags AssumeYes bool DateRange immich.DateRange // Set capture date range } -func initSack(ic *immich.ImmichClient, log *logger.Log, args []string) (*StackCmd, error) { +func initSack(ctx context.Context, common *cmd.SharedFlags, args []string) (*StackCmd, error) { cmd := flag.NewFlagSet("stack", flag.ExitOnError) validRange := immich.DateRange{} _ = validRange.Set("1850-01-04,2030-01-01") app := StackCmd{ - logger: log, - Immich: ic, - DateRange: validRange, + SharedFlags: common, + DateRange: validRange, } - + app.SharedFlags.SetFlags(cmd) cmd.BoolFunc("yes", "When true, assume Yes to all actions", func(s string) error { var err error app.AssumeYes, err = strconv.ParseBool(s) @@ -39,17 +37,24 @@ func initSack(ic *immich.ImmichClient, log *logger.Log, args []string) (*StackCm }) cmd.Var(&app.DateRange, "date", "Process only documents having a capture date in that range.") err := cmd.Parse(args) + if err != nil { + return nil, err + } + err = app.SharedFlags.Start(ctx) + if err != nil { + return nil, err + } return &app, err } -func NewStackCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.Log, args []string) error { - app, err := initSack(ic, log, args) +func NewStackCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { + app, err := initSack(ctx, common, args) if err != nil { return err } sb := stacking.NewStackBuilder() - log.MessageContinue(logger.OK, "Get server's assets...") + app.Jnl.Log.MessageContinue(logger.OK, "Get server's assets...") assetCount := 0 err = app.Immich.GetAllAssetsWithFilter(ctx, nil, func(a *immich.Asset) { @@ -66,15 +71,15 @@ func NewStackCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.L return err } stacks := sb.Stacks() - log.MessageTerminate(logger.OK, " %d received, %d stack(s) possible", assetCount, len(stacks)) + app.Jnl.Log.MessageTerminate(logger.OK, " %d received, %d stack(s) possible", assetCount, len(stacks)) for _, s := range stacks { - log.OK("Stack following images taken on %s", s.Date) + app.Jnl.Log.OK("Stack following images taken on %s", s.Date) cover := s.CoverID names := s.Names sort.Strings(names) for _, n := range names { - log.OK(" %s", n) + app.Jnl.Log.OK(" %s", n) } yes := app.AssumeYes if !app.AssumeYes { @@ -89,7 +94,7 @@ func NewStackCommand(ctx context.Context, ic *immich.ImmichClient, log *logger.L if yes { err := app.Immich.StackAssets(ctx, cover, s.IDs) if err != nil { - log.Warning("Can't stack images: %s", err) + app.Jnl.Log.Warning("Can't stack images: %s", err) } } } diff --git a/cmd/tool/tool.go b/cmd/tool/tool.go index fbb5a5b5..1c55a927 100644 --- a/cmd/tool/tool.go +++ b/cmd/tool/tool.go @@ -4,18 +4,17 @@ import ( "context" "fmt" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/cmd/album" - "github.com/simulot/immich-go/immich" - "github.com/simulot/immich-go/logger" ) -func CommandTool(ctx context.Context, ic *immich.ImmichClient, logger *logger.Log, args []string) error { +func CommandTool(ctx context.Context, common *cmd.SharedFlags, args []string) error { if len(args) > 0 { cmd := args[0] args = args[1:] if cmd == "album" { - return album.AlbumCommand(ctx, ic, logger, args) + return album.AlbumCommand(ctx, common, args) } } diff --git a/cmd/upload/e2e_upload_folder_test.go b/cmd/upload/e2e_upload_folder_test.go index 82b26956..1aa5048e 100644 --- a/cmd/upload/e2e_upload_folder_test.go +++ b/cmd/upload/e2e_upload_folder_test.go @@ -7,15 +7,31 @@ import ( "context" "errors" "fmt" - "os" "testing" "time" "github.com/joho/godotenv" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/immich" - "github.com/simulot/immich-go/logger" ) +var myEnv map[string]string + +func initMyEnv(t *testing.T) { + if len(myEnv) > 0 { + return + } + var err error + e, err := godotenv.Read("../../.env") + if err != nil { + t.Fatalf("cant initialize environment variables: %s", err) + } + myEnv = e + if myEnv["IMMICH_TESTFILES"] == "" { + t.Fatal("missing IMMICH_TESTFILES in .env file") + } +} + type immichSetupFunc func(ctx context.Context, t *testing.T, ic *immich.ImmichClient) func(t *testing.T) type testCase struct { @@ -28,12 +44,6 @@ type testCase struct { } func runCase(t *testing.T, tc testCase) { - var myEnv map[string]string - myEnv, err := godotenv.Read("../.env") - if err != nil { - t.Errorf("cant initialize environment variables: %s", err) - return - } host := myEnv["IMMICH_E2E_HOST"] if host == "" { @@ -49,24 +59,8 @@ func runCase(t *testing.T, tc testCase) { user = "debug.example.com" } - lf, err := os.Create(tc.name + ".log") - if err != nil { - t.Error(err) - return - } - defer lf.Close() - jnl := logger.NewJournal(logger.NewLogger(logger.Info, true, false).SetWriter(lf)) - - ic, err := immich.NewImmichClient(host, key, false) - if err != nil { - t.Error(err) - return - } - - if tc.APITrace { - ic.EnableAppTrace(true) - } ctx := context.Background() + ic, err := immich.NewImmichClient(host, key, false) if tc.resetImmich { err := resetImmich(ic, user) @@ -83,7 +77,19 @@ func runCase(t *testing.T, tc testCase) { } } - err = UploadCommand(ctx, ic, jnl, tc.args) + args := []string{"-server=" + host, "-key=" + key, "-log-file=" + tc.name + ".log"} + + if tc.APITrace { + args = append(args, "-api-trace=TRUE") + } + + args = append(args, tc.args...) + + app := cmd.SharedFlags{ + Immich: ic, + } + + err = UploadCommand(ctx, &app, args) if (tc.expectError && err == nil) || (!tc.expectError && err != nil) { t.Errorf("unexpected err: %v", err) return @@ -91,11 +97,13 @@ func runCase(t *testing.T, tc testCase) { } func TestE2eUpload(t *testing.T) { + initMyEnv(t) + tests := []testCase{ { name: "upload folder", args: []string{ - "../../test-data/low_high/high", + myEnv["IMMICH_TESTFILES"] + "/low_high/high", }, resetImmich: true, @@ -104,7 +112,7 @@ func TestE2eUpload(t *testing.T) { { name: "upload folder", args: []string{ - "../../test-data/low_high/high", + myEnv["IMMICH_TESTFILES"] + "/low_high/high", }, // resetImmich: true, @@ -114,7 +122,7 @@ func TestE2eUpload(t *testing.T) { name: "upload folder *.jpg", args: []string{ "-google-photos", - "../../test-data/test_folder/*.jpg", + myEnv["IMMICH_TESTFILES"] + "/test_folder/*.jpg", }, resetImmich: true, @@ -123,7 +131,7 @@ func TestE2eUpload(t *testing.T) { { name: "upload folder *.jpg", args: []string{ - "../../test-data/test_folder/*/*.jpg", + myEnv["IMMICH_TESTFILES"] + "/test_folder/*/*.jpg", }, // resetImmich: true, @@ -134,7 +142,7 @@ func TestE2eUpload(t *testing.T) { // name: "upload folder *.jpg - dry run", // args: []string{ // "-dry-run", - // "../../test-data/full_takeout (copy)/Takeout/Google Photos/Photos from 2023", + // myEnv["IMMICH_TESTFILES"] + "/full_takeout (copy)/Takeout/Google Photos/Photos from 2023", // }, // // resetImmich: true, @@ -145,7 +153,7 @@ func TestE2eUpload(t *testing.T) { name: "upload google photos", args: []string{ "-google-photos", - "../../test-data/low_high/Takeout", + myEnv["IMMICH_TESTFILES"] + "/low_high/Takeout", }, // resetImmich: true, expectError: false, @@ -155,7 +163,7 @@ func TestE2eUpload(t *testing.T) { args: []string{ "-stack-burst=FALSE", "-stack-jpg-raw=TRUE", - "../../test-data/burst/Tel", + myEnv["IMMICH_TESTFILES"] + "/burst/Tel", }, resetImmich: true, expectError: false, @@ -169,6 +177,8 @@ func TestE2eUpload(t *testing.T) { // PXL_20231006_063536303 should be archived // Google Photos/Album test 6-10-23/PXL_20231006_063851485.jpg.json is favorite and has a description func Test_DescriptionAndFavorite(t *testing.T) { + initMyEnv(t) + tc := testCase{ name: "Test_DescriptionAndFavorite", args: []string{ @@ -183,10 +193,12 @@ func Test_DescriptionAndFavorite(t *testing.T) { } func Test_PermissionError(t *testing.T) { + initMyEnv(t) + tc := testCase{ name: "Test_PermissionError", args: []string{ - "../../test-data/low_high/high", + myEnv["IMMICH_TESTFILES"] + "/low_high/high", }, resetImmich: true, expectError: false, @@ -195,11 +207,13 @@ func Test_PermissionError(t *testing.T) { } func Test_CreateAlbumFolder(t *testing.T) { + initMyEnv(t) + tc := testCase{ name: "Test_CreateAlbumFolder", args: []string{ "-create-album-folder", - "../../test-data/albums", + myEnv["IMMICH_TESTFILES"] + "/albums", }, resetImmich: true, expectError: false, @@ -209,11 +223,13 @@ func Test_CreateAlbumFolder(t *testing.T) { } func Test_XMP(t *testing.T) { + initMyEnv(t) + tc := testCase{ name: "Test_XMP", args: []string{ "-create-stacks=false", - "../../test-data/xmp", + myEnv["IMMICH_TESTFILES"] + "/xmp", }, resetImmich: true, expectError: false, @@ -223,12 +239,14 @@ func Test_XMP(t *testing.T) { } func Test_Album_Issue_119(t *testing.T) { + initMyEnv(t) + tc := []testCase{ { name: "Test_Album 1", args: []string{ "-album", "The Album", - "../../test-data/xmp/files", + myEnv["IMMICH_TESTFILES"] + "/xmp/files", }, setup: func(ctx context.Context, t *testing.T, ic *immich.ImmichClient) func(t *testing.T) { _, err := ic.CreateAlbum(ctx, "The Album", nil) @@ -245,7 +263,7 @@ func Test_Album_Issue_119(t *testing.T) { name: "Test_Album 2", args: []string{ "-album", "The Album", - "../../test-data/albums/Album test 6-10-23", + myEnv["IMMICH_TESTFILES"] + "/albums/Album test 6-10-23", }, resetImmich: false, expectError: false, @@ -257,12 +275,14 @@ func Test_Album_Issue_119(t *testing.T) { } func Test_Issue_126A(t *testing.T) { + initMyEnv(t) + tc := testCase{ name: "Test_Issue_126A", args: []string{ "-exclude-types", ".dng,.cr2,.arw,.rw2,.tif,.tiff,.gif,.psd", - "../../test-data/burst/PXL6", + myEnv["IMMICH_TESTFILES"] + "/burst/PXL6", }, resetImmich: true, expectError: false, @@ -272,12 +292,14 @@ func Test_Issue_126A(t *testing.T) { } func Test_Issue_126B(t *testing.T) { + initMyEnv(t) + tc := testCase{ name: "Test_Issue_126B", args: []string{ "-select-types", ".jpg", - "../../test-data/burst/PXL6", + myEnv["IMMICH_TESTFILES"] + "/burst/PXL6", }, resetImmich: true, expectError: false, @@ -287,11 +309,13 @@ func Test_Issue_126B(t *testing.T) { } func Test_Issue_129(t *testing.T) { + initMyEnv(t) + tc := testCase{ name: "Test_Issue_129", args: []string{ "-google-photos", - "../../test-data/Weird file names #88", + myEnv["IMMICH_TESTFILES"] + "/Weird file names #88", }, resetImmich: true, expectError: false, @@ -301,11 +325,13 @@ func Test_Issue_129(t *testing.T) { } func Test_Issue_128(t *testing.T) { + initMyEnv(t) + tc := testCase{ name: "Test_Issue_128", args: []string{ "-google-photos", - "../../test-data/Issue 128", + myEnv["IMMICH_TESTFILES"] + "/Issue 128", }, resetImmich: true, expectError: false, diff --git a/cmd/upload/upload.go b/cmd/upload/upload.go index 7e286cb0..7ef7e498 100644 --- a/cmd/upload/upload.go +++ b/cmd/upload/upload.go @@ -17,6 +17,7 @@ import ( "github.com/simulot/immich-go/browser" "github.com/simulot/immich-go/browser/files" "github.com/simulot/immich-go/browser/gp" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/helpers/fshelper" "github.com/simulot/immich-go/helpers/gen" "github.com/simulot/immich-go/helpers/myflag" @@ -26,24 +27,8 @@ import ( "github.com/simulot/immich-go/logger" ) -// iClient is an interface that implements the minimal immich client set of features for uploading -// interface used to mock up the client -type iClient interface { - GetAllAssetsWithFilter(context.Context, *immich.GetAssetOptions, func(*immich.Asset)) error - AssetUpload(context.Context, *browser.LocalAssetFile) (immich.AssetResponse, error) - DeleteAssets(context.Context, []string, bool) error - - GetAllAlbums(context.Context) ([]immich.AlbumSimplified, error) - AddAssetToAlbum(context.Context, string, []string) ([]immich.UpdateAlbumResult, error) - CreateAlbum(context.Context, string, []string) (immich.AlbumSimplified, error) - UpdateAssets(ctx context.Context, IDs []string, isArchived bool, isFavorite bool, latitude float64, longitude float64, removeParent bool, stackParentID string) error - StackAssets(ctx context.Context, cover string, IDs []string) error - UpdateAsset(ctx context.Context, ID string, a *browser.LocalAssetFile) (*immich.Asset, error) -} - type UpCmd struct { - client iClient // Immich client - Journal *logger.Journal // Log and journal + *cmd.SharedFlags // shared flags and immich client fsys []fs.FS // pseudo file system to browse @@ -80,15 +65,17 @@ type UpCmd struct { stacks *stacking.StackBuilder } -func NewUpCmd(ctx context.Context, ic iClient, log logger.Logger, args []string) (*UpCmd, error) { +func NewUpCmd(ctx context.Context, common *cmd.SharedFlags, args []string) (*UpCmd, error) { var err error cmd := flag.NewFlagSet("upload", flag.ExitOnError) app := UpCmd{ + SharedFlags: common, updateAlbums: map[string]map[string]any{}, - Journal: logger.NewJournal(log), - client: ic, } + + app.SharedFlags.SetFlags(cmd) + cmd.BoolFunc( "dry-run", "display actions but don't touch source or destination", @@ -165,7 +152,10 @@ func NewUpCmd(ctx context.Context, ic iClient, log logger.Logger, args []string) return nil, err } - app.Journal = logger.NewJournal(log) + err = app.SharedFlags.Start(ctx) + if err != nil { + return nil, err + } app.fsys, err = fshelper.ParsePath(cmd.Args(), app.GooglePhotos) if err != nil { @@ -175,9 +165,9 @@ func NewUpCmd(ctx context.Context, ic iClient, log logger.Logger, args []string) if app.CreateStacks || app.StackBurst || app.StackJpgRaws { app.stacks = stacking.NewStackBuilder() } - log.OK("Ask for server's assets...") + app.Jnl.Log.OK("Ask for server's assets...") var list []*immich.Asset - err = app.client.GetAllAssetsWithFilter(ctx, nil, func(a *immich.Asset) { + err = app.Immich.GetAllAssetsWithFilter(ctx, nil, func(a *immich.Asset) { if a.IsTrashed { return } @@ -186,7 +176,7 @@ func NewUpCmd(ctx context.Context, ic iClient, log logger.Logger, args []string) if err != nil { return nil, err } - log.OK("%d asset(s) received", len(list)) + app.Jnl.Log.OK("%d asset(s) received", len(list)) app.AssetIndex = &AssetIndex{ assets: list, @@ -197,8 +187,8 @@ func NewUpCmd(ctx context.Context, ic iClient, log logger.Logger, args []string) return &app, err } -func UploadCommand(ctx context.Context, ic iClient, log logger.Logger, args []string) error { - app, err := NewUpCmd(ctx, ic, log, args) +func UploadCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { + app, err := NewUpCmd(ctx, common, args) if err != nil { return err } @@ -206,7 +196,7 @@ func UploadCommand(ctx context.Context, ic iClient, log logger.Logger, args []st } func (app *UpCmd) journalAsset(a *browser.LocalAssetFile, action logger.Action, comment ...string) { - app.Journal.AddEntry(a.FileName, action, comment...) + app.Jnl.AddEntry(a.FileName, action, comment...) } func (app *UpCmd) Run(ctx context.Context, fsyss []fs.FS) error { @@ -215,18 +205,18 @@ func (app *UpCmd) Run(ctx context.Context, fsyss []fs.FS) error { switch { case app.GooglePhotos: - app.Journal.Message(logger.OK, "Browsing google take out archive...") + app.Jnl.Log.Message(logger.OK, "Browsing google take out archive...") browser, err = app.ReadGoogleTakeOut(ctx, fsyss) default: - app.Journal.Message(logger.OK, "Browsing folder(s)...") + app.Jnl.Log.Message(logger.OK, "Browsing folder(s)...") browser, err = app.ExploreLocalFolder(ctx, fsyss) } if err != nil { - app.Journal.Message(logger.Error, err.Error()) + app.Jnl.Log.Message(logger.Error, err.Error()) return err } - app.Journal.Message(logger.OK, "Done.") + app.Jnl.Log.Message(logger.OK, "Done.") assetChan := browser.Browse(ctx) assetLoop: @@ -253,7 +243,7 @@ assetLoop: if app.CreateStacks { stacks := app.stacks.Stacks() if len(stacks) > 0 { - app.Journal.OK("Creating stacks") + app.Jnl.Log.OK("Creating stacks") nextStack: for _, s := range stacks { switch { @@ -262,11 +252,11 @@ assetLoop: case !app.StackJpgRaws && s.StackType == stacking.StackRawJpg: continue nextStack } - app.Journal.OK(" Stacking %s...", strings.Join(s.Names, ", ")) + app.Jnl.Log.OK(" Stacking %s...", strings.Join(s.Names, ", ")) if !app.DryRun { - err = app.client.StackAssets(ctx, s.CoverID, s.IDs) + err = app.Immich.StackAssets(ctx, s.CoverID, s.IDs) if err != nil { - app.Journal.Warning("Can't stack images: %s", err) + app.Jnl.Log.Warning("Can't stack images: %s", err) } } } @@ -274,10 +264,10 @@ assetLoop: } if app.CreateAlbums || app.CreateAlbumAfterFolder || (app.KeepPartner && app.PartnerAlbum != "") || app.ImportIntoAlbum != "" { - app.Journal.OK("Managing albums") + app.Jnl.Log.OK("Managing albums") err = app.ManageAlbums(ctx) if err != nil { - app.Journal.Error(err.Error()) + app.Jnl.Log.Error(err.Error()) err = nil } } @@ -297,7 +287,7 @@ assetLoop: err = app.DeleteLocalAssets() } - app.Journal.Report() + app.Jnl.Report() return err } @@ -361,7 +351,7 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er }) } - app.Journal.DebugObject("handleAsset: LocalAssetFile=", a) + app.Jnl.Log.DebugObject("handleAsset: LocalAssetFile=", a) advice, err := app.AssetIndex.ShouldUpload(a) if err != nil { @@ -464,7 +454,7 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er Names := []string{} for _, al := range albums { Name := app.albumName(al) - app.Journal.DebugObject("Add asset to the album:", al) + app.Jnl.Log.DebugObject("Add asset to the album:", al) if app.GooglePhotos && Name == "" { continue @@ -486,9 +476,9 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er shouldUpdate = shouldUpdate || a.Archived if !app.DryRun && shouldUpdate { - _, err := app.client.UpdateAsset(ctx, ID, a) + _, err := app.Immich.UpdateAsset(ctx, ID, a) if err != nil { - app.Journal.Error("can't update the asset '%s': ", err) + app.Jnl.Log.Error("can't update the asset '%s': ", err) } } @@ -506,11 +496,11 @@ func (app *UpCmd) isInAlbum(a *browser.LocalAssetFile, album string) bool { func (app *UpCmd) ReadGoogleTakeOut(ctx context.Context, fsyss []fs.FS) (browser.Browser, error) { app.Delete = false - return gp.NewTakeout(ctx, app.Journal, fsyss...) + return gp.NewTakeout(ctx, app.Jnl, fsyss...) } func (app *UpCmd) ExploreLocalFolder(ctx context.Context, fsyss []fs.FS) (browser.Browser, error) { - return files.NewLocalFiles(ctx, app.Journal, fsyss...) + return files.NewLocalFiles(ctx, app.Jnl, fsyss...) } // UploadAsset upload the asset on the server @@ -531,7 +521,7 @@ func (app *UpCmd) UploadAsset(ctx context.Context, a *browser.LocalAssetFile) (s a.SideCar = &sc } - resp, err = app.client.AssetUpload(ctx, a) + resp, err = app.Immich.AssetUpload(ctx, a) } else { resp.ID = uuid.NewString() } @@ -576,36 +566,36 @@ func (app *UpCmd) AddToAlbum(id string, album string) { } func (app *UpCmd) DeleteLocalAssets() error { - app.Journal.OK("%d local assets to delete.", len(app.deleteLocalList)) + app.Jnl.Log.OK("%d local assets to delete.", len(app.deleteLocalList)) for _, a := range app.deleteLocalList { if !app.DryRun { - app.Journal.Warning("delete file %q", a.Title) + app.Jnl.Log.Warning("delete file %q", a.Title) err := a.Remove() if err != nil { return err } } else { - app.Journal.Warning("file %q not deleted, dry run mode", a.Title) + app.Jnl.Log.Warning("file %q not deleted, dry run mode", a.Title) } } return nil } func (app *UpCmd) DeleteServerAssets(ctx context.Context, ids []string) error { - app.Journal.Warning("%d server assets to delete.", len(ids)) + app.Jnl.Log.Warning("%d server assets to delete.", len(ids)) if !app.DryRun { - err := app.client.DeleteAssets(ctx, ids, false) + err := app.Immich.DeleteAssets(ctx, ids, false) return err } - app.Journal.Warning("%d server assets to delete. skipped dry-run mode", len(ids)) + app.Jnl.Log.Warning("%d server assets to delete. skipped dry-run mode", len(ids)) return nil } func (app *UpCmd) ManageAlbums(ctx context.Context) error { if len(app.updateAlbums) > 0 { - serverAlbums, err := app.client.GetAllAlbums(ctx) + serverAlbums, err := app.Immich.GetAllAlbums(ctx) if err != nil { return fmt.Errorf("can't get the album list from the server: %w", err) } @@ -615,8 +605,8 @@ func (app *UpCmd) ManageAlbums(ctx context.Context) error { if sal.AlbumName == album { found = true if !app.DryRun { - app.Journal.OK("Update the album %s", album) - rr, err := app.client.AddAssetToAlbum(ctx, sal.ID, gen.MapKeys(list)) + app.Jnl.Log.OK("Update the album %s", album) + rr, err := app.Immich.AddAssetToAlbum(ctx, sal.ID, gen.MapKeys(list)) if err != nil { return fmt.Errorf("can't update the album list from the server: %w", err) } @@ -626,14 +616,14 @@ func (app *UpCmd) ManageAlbums(ctx context.Context) error { added++ } if !r.Success && r.Error != "duplicate" { - app.Journal.Warning("%s: %s", r.ID, r.Error) + app.Jnl.Log.Warning("%s: %s", r.ID, r.Error) } } if added > 0 { - app.Journal.OK("%d asset(s) added to the album %q", added, album) + app.Jnl.Log.OK("%d asset(s) added to the album %q", added, album) } } else { - app.Journal.OK("Update album %s skipped - dry run mode", album) + app.Jnl.Log.OK("Update album %s skipped - dry run mode", album) } } } @@ -642,14 +632,14 @@ func (app *UpCmd) ManageAlbums(ctx context.Context) error { } if list != nil { if !app.DryRun { - app.Journal.OK("Create the album %s", album) + app.Jnl.Log.OK("Create the album %s", album) - _, err := app.client.CreateAlbum(ctx, album, gen.MapKeys(list)) + _, err := app.Immich.CreateAlbum(ctx, album, gen.MapKeys(list)) if err != nil { return fmt.Errorf("can't create the album list from the server: %w", err) } } else { - app.Journal.OK("Create the album %s skipped - dry run mode", album) + app.Jnl.Log.OK("Create the album %s skipped - dry run mode", album) } } } diff --git a/cmd/upload/upload_test.go b/cmd/upload/upload_test.go index 541f108d..4a888d92 100644 --- a/cmd/upload/upload_test.go +++ b/cmd/upload/upload_test.go @@ -11,6 +11,7 @@ import ( "github.com/kr/pretty" "github.com/simulot/immich-go/browser" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/helpers/gen" "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/logger" @@ -54,6 +55,36 @@ func (c *stubIC) UpdateAsset(ctx context.Context, id string, a *browser.LocalAss return nil, nil } +func (c *stubIC) EnableAppTrace(bool) {} + +func (c *stubIC) GetServerStatistics(ctx context.Context) (immich.ServerStatistics, error) { + return immich.ServerStatistics{}, nil +} + +func (c *stubIC) PingServer(ctx context.Context) error { + return nil +} + +func (c *stubIC) SetDeviceUUID(string) {} + +func (c *stubIC) SetEndPoint(string) {} + +func (c *stubIC) ValidateConnection(ctx context.Context) (immich.User, error) { + return immich.User{}, nil +} + +func (c *stubIC) GetAssetAlbums(ctx context.Context, id string) ([]immich.AlbumSimplified, error) { + return nil, nil +} + +func (c *stubIC) GetAllAssets(ctx context.Context, opt *immich.GetAssetOptions) ([]*immich.Asset, error) { + return nil, nil +} + +func (c *stubIC) DeleteAlbum(ctx context.Context, id string) error { + return nil +} + // type mockedBrowser struct { // assets []assets.LocalAssetFile // } @@ -447,10 +478,15 @@ func TestUpload(t *testing.T) { ic := &icCatchUploadsAssets{ albums: map[string][]string{}, } - log := logger.NoLogger{} + log := logger.NoLog{} ctx := context.Background() - app, err := NewUpCmd(ctx, ic, log, tc.args) + serv := cmd.SharedFlags{ + Immich: ic, + Jnl: logger.NewJournal(&log), + } + + app, err := NewUpCmd(ctx, &serv, tc.args) if err != nil { t.Errorf("can't instantiate the UploadCmd: %s", err) return diff --git a/immich/client.go b/immich/client.go index 345bbc5c..99105f56 100644 --- a/immich/client.go +++ b/immich/client.go @@ -25,19 +25,16 @@ type ImmichClient struct { APITrace bool } -func (ic *ImmichClient) SetEndPoint(endPoint string) *ImmichClient { +func (ic *ImmichClient) SetEndPoint(endPoint string) { ic.endPoint = endPoint - return ic } -func (ic *ImmichClient) SetDeviceUUID(deviceUUID string) *ImmichClient { +func (ic *ImmichClient) SetDeviceUUID(deviceUUID string) { ic.DeviceUUID = deviceUUID - return ic } -func (ic *ImmichClient) EnableAppTrace(state bool) *ImmichClient { +func (ic *ImmichClient) EnableAppTrace(state bool) { ic.APITrace = state - return ic } // Create a new ImmichClient diff --git a/immich/immich.go b/immich/immich.go index 97d28dda..3be7177e 100644 --- a/immich/immich.go +++ b/immich/immich.go @@ -1,14 +1,40 @@ package immich import ( + "context" "encoding/json" "errors" "sync" "time" + "github.com/simulot/immich-go/browser" "github.com/simulot/immich-go/helpers/tzone" ) +// ImmichInterface is an interface that implements the minimal immich client set of features for uploading +// interface used to mock up the client +type ImmichInterface interface { + GetAllAssetsWithFilter(context.Context, *GetAssetOptions, func(*Asset)) error + AssetUpload(context.Context, *browser.LocalAssetFile) (AssetResponse, error) + DeleteAssets(context.Context, []string, bool) error + + GetAllAlbums(context.Context) ([]AlbumSimplified, error) + AddAssetToAlbum(context.Context, string, []string) ([]UpdateAlbumResult, error) + CreateAlbum(context.Context, string, []string) (AlbumSimplified, error) + UpdateAssets(ctx context.Context, IDs []string, isArchived bool, isFavorite bool, latitude float64, longitude float64, removeParent bool, stackParentID string) error + StackAssets(ctx context.Context, cover string, IDs []string) error + UpdateAsset(ctx context.Context, ID string, a *browser.LocalAssetFile) (*Asset, error) + SetEndPoint(string) + EnableAppTrace(bool) + SetDeviceUUID(string) + PingServer(ctx context.Context) error + ValidateConnection(ctx context.Context) (User, error) + GetServerStatistics(ctx context.Context) (ServerStatistics, error) + GetAssetAlbums(ctx context.Context, ID string) ([]AlbumSimplified, error) + GetAllAssets(ctx context.Context, opt *GetAssetOptions) ([]*Asset, error) + DeleteAlbum(ctx context.Context, id string) error +} + type UnsupportedMedia struct { msg string } diff --git a/logger/journal.go b/logger/journal.go index ea2be9c5..0dbac89f 100644 --- a/logger/journal.go +++ b/logger/journal.go @@ -8,7 +8,7 @@ import ( type Journal struct { mut sync.Mutex counts map[Action]int - Logger + Log Logger } type Action string @@ -39,7 +39,7 @@ const ( func NewJournal(log Logger) *Journal { return &Journal{ // files: map[string]Entries{}, - Logger: log, + Log: log, counts: map[Action]int{}, } } @@ -49,16 +49,16 @@ func (j *Journal) AddEntry(file string, action Action, comment ...string) { return } c := strings.Join(comment, ", ") - if j.Logger != nil { + if j.Log != nil { switch action { case ERROR, ServerError: - j.Logger.Error("%-25s: %s: %s", action, file, c) + j.Log.Error("%-25s: %s: %s", action, file, c) case DiscoveredFile: - j.Logger.Debug("%-25s: %s: %s", action, file, c) + j.Log.Debug("%-25s: %s: %s", action, file, c) case Uploaded: - j.Logger.OK("%-25s: %s: %s", action, file, c) + j.Log.OK("%-25s: %s: %s", action, file, c) default: - j.Logger.Info("%-25s: %s: %s", action, file, c) + j.Log.Info("%-25s: %s: %s", action, file, c) } } j.mut.Lock() @@ -72,27 +72,27 @@ func (j *Journal) AddEntry(file string, action Action, comment ...string) { func (j *Journal) Report() { checkFiles := j.counts[ScannedImage] + j.counts[ScannedVideo] + j.counts[Metadata] + j.counts[Unsupported] + j.counts[FailedVideo] + j.counts[Discarded] handledFiles := j.counts[NotSelected] + j.counts[LocalDuplicate] + j.counts[ServerDuplicate] + j.counts[ServerBetter] + j.counts[Uploaded] + j.counts[Upgraded] + j.counts[ServerError] - j.Logger.OK("Scan of the sources:") - j.Logger.OK("%6d files in the input", j.counts[DiscoveredFile]) - j.Logger.OK("--------------------------------------------------------") - j.Logger.OK("%6d photos", j.counts[ScannedImage]) - j.Logger.OK("%6d videos", j.counts[ScannedVideo]) - j.Logger.OK("%6d metadata files", j.counts[Metadata]) - j.Logger.OK("%6d files with metadata", j.counts[AssociatedMetadata]) - j.Logger.OK("%6d discarded files", j.counts[Discarded]) - j.Logger.OK("%6d files having a type not supported", j.counts[Unsupported]) - j.Logger.OK("%6d discarded files because in folder failed videos", j.counts[FailedVideo]) + j.Log.OK("Scan of the sources:") + j.Log.OK("%6d files in the input", j.counts[DiscoveredFile]) + j.Log.OK("--------------------------------------------------------") + j.Log.OK("%6d photos", j.counts[ScannedImage]) + j.Log.OK("%6d videos", j.counts[ScannedVideo]) + j.Log.OK("%6d metadata files", j.counts[Metadata]) + j.Log.OK("%6d files with metadata", j.counts[AssociatedMetadata]) + j.Log.OK("%6d discarded files", j.counts[Discarded]) + j.Log.OK("%6d files having a type not supported", j.counts[Unsupported]) + j.Log.OK("%6d discarded files because in folder failed videos", j.counts[FailedVideo]) - j.Logger.OK("%6d input total (difference %d)", checkFiles, j.counts[DiscoveredFile]-checkFiles) - j.Logger.OK("--------------------------------------------------------") + j.Log.OK("%6d input total (difference %d)", checkFiles, j.counts[DiscoveredFile]-checkFiles) + j.Log.OK("--------------------------------------------------------") - j.Logger.OK("%6d uploaded files on the server", j.counts[Uploaded]) - j.Logger.OK("%6d upgraded files on the server", j.counts[Upgraded]) - j.Logger.OK("%6d files already on the server", j.counts[ServerDuplicate]) - j.Logger.OK("%6d discarded files because of options", j.counts[NotSelected]) - j.Logger.OK("%6d discarded files because duplicated in the input", j.counts[LocalDuplicate]) - j.Logger.OK("%6d discarded files because server has a better image", j.counts[ServerBetter]) - j.Logger.OK("%6d errors when uploading", j.counts[ServerError]) + j.Log.OK("%6d uploaded files on the server", j.counts[Uploaded]) + j.Log.OK("%6d upgraded files on the server", j.counts[Upgraded]) + j.Log.OK("%6d files already on the server", j.counts[ServerDuplicate]) + j.Log.OK("%6d discarded files because of options", j.counts[NotSelected]) + j.Log.OK("%6d discarded files because duplicated in the input", j.counts[LocalDuplicate]) + j.Log.OK("%6d discarded files because server has a better image", j.counts[ServerBetter]) + j.Log.OK("%6d errors when uploading", j.counts[ServerError]) - j.Logger.OK("%6d handled total (difference %d)", handledFiles, j.counts[ScannedImage]+j.counts[ScannedVideo]-handledFiles) + j.Log.OK("%6d handled total (difference %d)", handledFiles, j.counts[ScannedImage]+j.counts[ScannedVideo]-handledFiles) } diff --git a/logger/log.go b/logger/log.go index 1ff390c0..b7f48fe2 100644 --- a/logger/log.go +++ b/logger/log.go @@ -112,13 +112,12 @@ func (l *Log) SetColors(flag bool) { } } -func (l *Log) SetWriter(w io.WriteCloser) *Log { +func (l *Log) SetWriter(w io.WriteCloser) { if l != nil && w != nil { l.out = w l.noColors = true l.colorStrings = map[Level]string{} } - return l } func (l *Log) Debug(f string, v ...any) { @@ -165,6 +164,8 @@ func (l *Log) DebugObject(name string, v any) { func (l *Log) Info(f string, v ...any) { if l == nil || l.out == nil { + fmt.Printf(f, v...) + fmt.Println() return } l.Message(Info, f, v...) @@ -172,6 +173,8 @@ func (l *Log) Info(f string, v ...any) { func (l *Log) OK(f string, v ...any) { if l == nil || l.out == nil { + fmt.Printf(f, v...) + fmt.Println() return } l.Message(OK, f, v...) @@ -179,6 +182,8 @@ func (l *Log) OK(f string, v ...any) { func (l *Log) Warning(f string, v ...any) { if l == nil || l.out == nil { + fmt.Printf(f, v...) + fmt.Println() return } l.Message(Warning, f, v...) @@ -186,6 +191,8 @@ func (l *Log) Warning(f string, v ...any) { func (l *Log) Error(f string, v ...any) { if l == nil || l.out == nil { + fmt.Printf(f, v...) + fmt.Println() return } l.Message(Error, f, v...) @@ -193,6 +200,8 @@ func (l *Log) Error(f string, v ...any) { func (l *Log) Fatal(f string, v ...any) { if l == nil || l.out == nil { + fmt.Printf(f, v...) + fmt.Println() return } l.Message(Fatal, f, v...) diff --git a/logger/logger.go b/logger/logger.go index 4f204ea0..e4421203 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -1,5 +1,7 @@ package logger +import "io" + type Logger interface { Debug(f string, v ...any) DebugObject(name string, v any) @@ -12,4 +14,8 @@ type Logger interface { Progress(level Level, f string, v ...any) MessageContinue(level Level, f string, v ...any) MessageTerminate(level Level, f string, v ...any) + SetWriter(io.WriteCloser) + SetLevel(Level) + SetColors(bool) + SetDebugFlag(bool) } diff --git a/logger/nologger.go b/logger/nologger.go index ec379b4b..753c3394 100644 --- a/logger/nologger.go +++ b/logger/nologger.go @@ -1,15 +1,21 @@ package logger -type NoLogger struct{} +import "io" -func (NoLogger) Debug(f string, v ...any) {} -func (NoLogger) DebugObject(name string, v any) {} -func (NoLogger) Info(f string, v ...any) {} -func (NoLogger) OK(f string, v ...any) {} -func (NoLogger) Warning(f string, v ...any) {} -func (NoLogger) Error(f string, v ...any) {} -func (NoLogger) Fatal(f string, v ...any) {} -func (NoLogger) Message(level Level, f string, v ...any) {} -func (NoLogger) Progress(level Level, f string, v ...any) {} -func (NoLogger) MessageContinue(level Level, f string, v ...any) {} -func (NoLogger) MessageTerminate(level Level, f string, v ...any) {} +type NoLog struct{} + +func (NoLog) Debug(f string, v ...any) {} +func (NoLog) DebugObject(name string, v any) {} +func (NoLog) Info(f string, v ...any) {} +func (NoLog) OK(f string, v ...any) {} +func (NoLog) Warning(f string, v ...any) {} +func (NoLog) Error(f string, v ...any) {} +func (NoLog) Fatal(f string, v ...any) {} +func (NoLog) Message(level Level, f string, v ...any) {} +func (NoLog) Progress(level Level, f string, v ...any) {} +func (NoLog) MessageContinue(level Level, f string, v ...any) {} +func (NoLog) MessageTerminate(level Level, f string, v ...any) {} +func (NoLog) SetWriter(io.WriteCloser) {} +func (NoLog) SetLevel(Level) {} +func (NoLog) SetColors(bool) {} +func (NoLog) SetDebugFlag(bool) {} diff --git a/main.go b/main.go index 83172013..3e20f716 100644 --- a/main.go +++ b/main.go @@ -7,17 +7,13 @@ import ( "fmt" "os" "os/signal" - "runtime" - "strings" + "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/cmd/duplicate" "github.com/simulot/immich-go/cmd/metadata" "github.com/simulot/immich-go/cmd/stack" "github.com/simulot/immich-go/cmd/tool" "github.com/simulot/immich-go/cmd/upload" - "github.com/simulot/immich-go/helpers/myflag" - "github.com/simulot/immich-go/helpers/tzone" - "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/logger" ) @@ -29,9 +25,7 @@ var ( func main() { var err error - log := logger.NewLogger(logger.OK, true, false) - defer log.Close() - log.OK("immich-go %s, commit %s, built at %s\n", version, commit, date) + fmt.Printf("immich-go %s, commit %s, built at %s\n", version, commit, date) // Create a context with cancel function to gracefully handle Ctrl+C events ctx, cancel := context.WithCancel(context.Background()) @@ -50,135 +44,54 @@ func main() { case <-ctx.Done(): err = ctx.Err() default: - log, err = Run(ctx, log) + err = Run(ctx) } if err != nil { - log.Error(err.Error()) - log.Close() - os.Exit(1) //nolint:gocritic + os.Exit(1) } - log.OK("Done.") } -type Application struct { - Server string // Immich server address (http://:2283/api or https:///api) - API string // Immich api endpoint (http://container_ip:3301) - Key string // API Key - DeviceUUID string // Set a device UUID - APITrace bool // Enable API call traces - NoLogColors bool // Disable log colors - LogLevel string // Idicate the log level - Debug bool // Enable the debug mode - TimeZone string // Override default TZ - SkipSSL bool // Skip SSL Verification - - Immich *immich.ImmichClient // Immich client - Logger *logger.Log // Program's logger - LogFile string // Log file -} - -func Run(ctx context.Context, log *logger.Log) (*logger.Log, error) { - var err error - - app := Application{} - flag.StringVar(&app.Server, "server", "", "Immich server address (http://:2283 or https://)") - flag.StringVar(&app.API, "api", "", "Immich api endpoint (http://container_ip:3301)") - flag.StringVar(&app.Key, "key", "", "API Key") - flag.StringVar(&app.DeviceUUID, "device-uuid", "", "Set a device UUID") - flag.BoolFunc("no-colors-log", "Disable colors on logs", myflag.BoolFlagFn(&app.NoLogColors, runtime.GOOS == "windows")) - flag.StringVar(&app.LogLevel, "log-level", "ok", "Log level (Error|Warning|OK|Info), default OK") - flag.StringVar(&app.LogFile, "log-file", "", "Write log messages into the file") - flag.BoolFunc("api-trace", "enable api call traces", myflag.BoolFlagFn(&app.APITrace, false)) - flag.BoolFunc("debug", "enable debug messages", myflag.BoolFlagFn(&app.Debug, false)) - flag.StringVar(&app.TimeZone, "time-zone", "", "Override the system time zone") - flag.BoolFunc("skip-verify-ssl", "Skip SSL verification", myflag.BoolFlagFn(&app.SkipSSL, false)) - flag.Parse() - - app.Server = strings.TrimSuffix(app.Server, "/") - - _, err = tzone.SetLocal(app.TimeZone) - if err != nil { - return log, err - } - - if app.LogFile != "" { - flog, err := os.Create(app.LogFile) - if err != nil { - return log, fmt.Errorf("can't open the log file: %w", err) - } - log.SetWriter(flog) - log.OK("immich-go %s, commit %s, built at %s\n", version, commit, date) - } - - switch { - case app.Server == "" && app.API == "": - err = errors.Join(err, errors.New("missing -server, Immich server address (http://:2283 or https://)")) - case app.Server != "" && app.API != "": - err = errors.Join(err, errors.New("give either the -server or the -api option")) - } - if app.Key == "" { - err = errors.Join(err, errors.New("missing -key")) - } - - logLevel, e := logger.StringToLevel(app.LogLevel) - if err != nil { - err = errors.Join(err, e) - } +func Run(ctx context.Context) error { + log := logger.NewLogger(logger.OK, true, false) + defer log.Close() - if len(flag.Args()) == 0 { - err = errors.Join(err, errors.New("missing command upload|duplicate|stack")) + app := cmd.SharedFlags{ + Jnl: logger.NewJournal(log), } + fs := flag.NewFlagSet("main", flag.ExitOnError) + app.SetFlags(fs) - log.SetLevel(logLevel) - log.SetColors(!app.NoLogColors) - log.SetDebugFlag(app.Debug) - - app.Logger = log - + err := fs.Parse(os.Args[1:]) if err != nil { - return app.Logger, err + return err } - app.Immich, err = immich.NewImmichClient(app.Server, app.Key, app.SkipSSL) - if err != nil { - return app.Logger, err - } - if app.API != "" { - app.Immich.SetEndPoint(app.API) - } - if app.APITrace { - app.Immich.EnableAppTrace(true) + if len(fs.Args()) == 0 { + err = errors.Join(err, errors.New("missing command upload|duplicate|stack|tool")) } - if app.DeviceUUID != "" { - app.Immich.SetDeviceUUID(app.DeviceUUID) - } - - err = app.Immich.PingServer(ctx) - if err != nil { - return app.Logger, err - } - app.Logger.OK("Server status: OK") - user, err := app.Immich.ValidateConnection(ctx) if err != nil { - return app.Logger, err + return err } - app.Logger.Info("Connected, user: %s", user.Email) - cmd := flag.Args()[0] + cmd := fs.Args()[0] switch cmd { case "upload": - err = upload.UploadCommand(ctx, app.Immich, app.Logger, flag.Args()[1:]) + err = upload.UploadCommand(ctx, &app, fs.Args()[1:]) case "duplicate": - err = duplicate.DuplicateCommand(ctx, app.Immich, app.Logger, flag.Args()[1:]) + err = duplicate.DuplicateCommand(ctx, &app, fs.Args()[1:]) case "metadata": - err = metadata.MetadataCommand(ctx, app.Immich, app.Logger, flag.Args()[1:]) + err = metadata.MetadataCommand(ctx, &app, fs.Args()[1:]) case "stack": - err = stack.NewStackCommand(ctx, app.Immich, app.Logger, flag.Args()[1:]) + err = stack.NewStackCommand(ctx, &app, fs.Args()[1:]) case "tool": - err = tool.CommandTool(ctx, app.Immich, app.Logger, flag.Args()[1:]) + err = tool.CommandTool(ctx, &app, fs.Args()[1:]) default: err = fmt.Errorf("unknown command: %q", cmd) } - return app.Logger, err + + if err != nil { + log.Error(err.Error()) + } + return err }