diff --git a/browser/browser.go b/browser/browser.go index ae056e0f..299c8ffe 100644 --- a/browser/browser.go +++ b/browser/browser.go @@ -5,5 +5,6 @@ import ( ) type Browser interface { + Prepare(cxt context.Context) error Browse(cxt context.Context) chan *LocalAssetFile } diff --git a/browser/files/localassets.go b/browser/files/localassets.go index 89f72581..732d6b47 100644 --- a/browser/files/localassets.go +++ b/browser/files/localassets.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/charmbracelet/log" "github.com/simulot/immich-go/browser" "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/immich/metadata" @@ -16,14 +17,15 @@ import ( ) type LocalAssetBrowser struct { - fsyss []fs.FS - albums map[string]string - log *logger.Journal + fsyss []fs.FS + albums map[string]string + + log *logger.LogAndCount[logger.UpLdAction] sm immich.SupportedMedia whenNoDate string } -func NewLocalFiles(ctx context.Context, log *logger.Journal, fsyss ...fs.FS) (*LocalAssetBrowser, error) { +func NewLocalFiles(ctx context.Context, log *logger.LogAndCount[logger.UpLdAction], fsyss ...fs.FS) (*LocalAssetBrowser, error) { return &LocalAssetBrowser{ fsyss: fsyss, albums: map[string]string{}, @@ -32,6 +34,10 @@ func NewLocalFiles(ctx context.Context, log *logger.Journal, fsyss ...fs.FS) (*L }, nil } +func (la *LocalAssetBrowser) Prepare(ctx context.Context) error { + return nil +} + func (la *LocalAssetBrowser) SetSupportedMedia(sm immich.SupportedMedia) *LocalAssetBrowser { la.sm = sm return la @@ -45,6 +51,7 @@ func (la *LocalAssetBrowser) SetWhenNoDate(opt string) *LocalAssetBrowser { var toOldDate = time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC) func (la *LocalAssetBrowser) Browse(ctx context.Context) chan *browser.LocalAssetFile { + la.log.Stage("Parsing source") fileChan := make(chan *browser.LocalAssetFile) // Browse all given FS to collect the list of files go func(ctx context.Context) { @@ -98,23 +105,23 @@ func (la *LocalAssetBrowser) handleFolder(ctx context.Context, fsys fs.FS, fileC name := e.Name() fileName := path.Join(folder, name) ext := strings.ToLower(path.Ext(name)) - la.log.AddEntry(fileName, logger.DiscoveredFile, "") + la.log.AddEntry(log.InfoLevel, logger.UpldDiscoveredFile, fileName) t := la.sm.TypeFromExt(ext) switch t { default: - la.log.AddEntry(fileName, logger.Unsupported, "") + la.log.AddEntry(log.InfoLevel, logger.UpldDiscarded, fileName, "reason", "unknown extension") continue case immich.TypeIgnored: - la.log.AddEntry(name, logger.Discarded, "File ignored") + la.log.AddEntry(log.InfoLevel, logger.UpldDiscarded, fileName, "reason", "useless file type") continue case immich.TypeSidecar: - la.log.AddEntry(name, logger.Metadata, "") + la.log.AddEntry(log.InfoLevel, logger.UpldMetadata, fileName) continue case immich.TypeImage: - la.log.AddEntry(name, logger.ScannedImage, "") + la.log.AddEntry(log.InfoLevel, logger.UpldScannedImage, fileName) case immich.TypeVideo: - la.log.AddEntry(name, logger.ScannedVideo, "") + la.log.AddEntry(log.InfoLevel, logger.UpldScannedVideo, fileName) } f := browser.LocalAssetFile{ @@ -172,7 +179,7 @@ func (la *LocalAssetBrowser) checkSidecar(f *browser.LocalAssetFile, entries []f FileName: path.Join(dir, e.Name()), OnFSsys: true, } - la.log.AddEntry(name, logger.AssociatedMetadata, "") + la.log.AddEntry(log.InfoLevel, logger.UpldAssociatedMetadata, "with", f.FileName) return true } } diff --git a/browser/files/localassets_test.go b/browser/files/localassets_test.go index 1380d442..6fb9e41f 100644 --- a/browser/files/localassets_test.go +++ b/browser/files/localassets_test.go @@ -3,11 +3,13 @@ package files_test import ( "context" "errors" + "io" "path" "reflect" "sort" "testing" + "github.com/charmbracelet/log" "github.com/kr/pretty" "github.com/psanford/memfs" "github.com/simulot/immich-go/browser/files" @@ -74,8 +76,10 @@ func TestLocalAssets(t *testing.T) { return } ctx := context.Background() - - b, err := files.NewLocalFiles(ctx, logger.NewJournal(logger.NoLog{}), fsys) + l := log.New(io.Discard) + cnt := logger.NewCounters[logger.UpLdAction]() + lc := logger.NewLogAndCount[logger.UpLdAction](l, logger.SendNop, cnt) + b, err := files.NewLocalFiles(ctx, lc, fsys) if err != nil { t.Error(err) } diff --git a/browser/gp/googlephotos.go b/browser/gp/googlephotos.go index d73a1e16..dbd80ccb 100644 --- a/browser/gp/googlephotos.go +++ b/browser/gp/googlephotos.go @@ -10,6 +10,7 @@ import ( "strings" "unicode/utf8" + "github.com/charmbracelet/log" "github.com/simulot/immich-go/browser" "github.com/simulot/immich-go/helpers/fshelper" "github.com/simulot/immich-go/helpers/gen" @@ -23,7 +24,7 @@ type Takeout struct { jsonByYear map[jsonKey]*GoogleMetaData // assets by year of capture and base name uploaded map[fileKey]any // track files already uploaded albums map[string]string // tack album names by folder - jnl *logger.Journal + log *logger.LogAndCount[logger.UpLdAction] sm immich.SupportedMedia } @@ -54,27 +55,22 @@ type jsonKey struct { year int } -func NewTakeout(ctx context.Context, jnl *logger.Journal, sm immich.SupportedMedia, fsyss ...fs.FS) (*Takeout, error) { +func NewTakeout(ctx context.Context, log *logger.LogAndCount[logger.UpLdAction], sm immich.SupportedMedia, fsyss ...fs.FS) (*Takeout, error) { to := Takeout{ fsyss: fsyss, jsonByYear: map[jsonKey]*GoogleMetaData{}, albums: map[string]string{}, - jnl: jnl, + log: log, sm: sm, } - err := to.passOne(ctx) - if err != nil { - return nil, err - } - - to.solvePuzzle() - return &to, err + return &to, nil } // passOne scans all files in all walker to build the file catalog of the archive // metadata files content is read and kept -func (to *Takeout) passOne(ctx context.Context) error { +func (to *Takeout) Prepare(ctx context.Context) error { + to.log.Stage("Parsing takeout files") to.catalogs = map[fs.FS]walkerCatalog{} for _, w := range to.fsyss { to.catalogs[w] = walkerCatalog{} @@ -83,6 +79,8 @@ func (to *Takeout) passOne(ctx context.Context) error { return err } } + to.log.Stage("Associating metadata with assets") + to.solvePuzzle() return nil } @@ -100,14 +98,13 @@ func (to *Takeout) passOneFsWalk(ctx context.Context, w fs.FS) error { if d.IsDir() { return nil } - - to.jnl.AddEntry(name, logger.DiscoveredFile, "") + to.log.AddEntry(log.InfoLevel, logger.UpldDiscoveredFile, name) dir, base := path.Split(name) dir = strings.TrimSuffix(dir, "/") ext := strings.ToLower(path.Ext(base)) if slices.Contains(uselessFiles, base) { - to.jnl.AddEntry(name, logger.Discarded, "Useless file") + to.log.AddEntry(log.InfoLevel, logger.UpldDiscarded, "reason", "useless file") return nil } @@ -126,35 +123,37 @@ func (to *Takeout) passOneFsWalk(ctx context.Context, w fs.FS) error { switch { case md.isAsset(): to.addJSON(dir, base, md) - to.jnl.AddEntry(name, logger.Metadata, "Asset Title: "+md.Title) + to.log.AddEntry(log.InfoLevel, logger.UpldMetadata, name, "type", "Google sidecar file", "asset_original_name", md.Title) case md.isAlbum(): to.albums[dir] = md.Title - to.jnl.AddEntry(name, logger.Metadata, "Album title: "+md.Title) + to.log.AddEntry(log.InfoLevel, logger.UpldMetadata, name, "type", "Google album file", "asset_original_name", md.Title) default: - to.jnl.AddEntry(name, logger.Discarded, "Unknown json file") + // TODO add support for old takeouts #212 + to.log.AddEntry(log.InfoLevel, logger.UpldDiscarded, name, "reason", "unknown JSON file") return nil } } else { - to.jnl.AddEntry(name, logger.Discarded, "Unknown json file") + // TODO add support for old takeouts #212 + to.log.AddEntry(log.InfoLevel, logger.UpldDiscarded, name, "reason", "unknown JSON file") return nil } default: t := to.sm.TypeFromExt(ext) switch t { case immich.TypeUnknown: - to.jnl.AddEntry(name, logger.Unsupported, "") + to.log.AddEntry(log.InfoLevel, logger.UpldDiscarded, name, "reason", "unknown extension") return nil case immich.TypeIgnored: - to.jnl.AddEntry(name, logger.Discarded, "File ignored") + to.log.AddEntry(log.InfoLevel, logger.UpldDiscarded, name, "reason", "useless file") return nil case immich.TypeVideo: if strings.Contains(name, "Failed Videos") { - to.jnl.AddEntry(name, logger.FailedVideo, "") + to.log.AddEntry(log.InfoLevel, logger.UpldDiscarded, name, "reason", "can't upload failed video") return nil } - to.jnl.AddEntry(name, logger.ScannedVideo, "") + to.log.AddEntry(log.InfoLevel, logger.UpldScannedVideo, name) case immich.TypeImage: - to.jnl.AddEntry(name, logger.ScannedImage, "") + to.log.AddEntry(log.InfoLevel, logger.UpldScannedImage, name) } dirCatalog.files[base] = fileInfo{ length: int(finfo.Size()), @@ -218,7 +217,7 @@ var matchers = []matcherFn{ // func (to *Takeout) solvePuzzle() { - to.jnl.Log.OK("Associating JSON and assets...") + to.log.Print("Associating JSON and assets...") jsonKeys := gen.MapKeys(to.jsonByYear) sort.Slice(jsonKeys, func(i, j int) bool { yd := jsonKeys[i].year - jsonKeys[j].year @@ -249,7 +248,7 @@ func (to *Takeout) solvePuzzle() { for f := range l.files { if l.files[f].md == nil { if matcher(k.name, f, to.sm) { - to.jnl.AddEntry(path.Join(d, f), logger.AssociatedMetadata, fmt.Sprintf("%s (%d)", k.name, k.year)) + to.log.AddEntry(log.InfoLevel, logger.UpldAssociatedMetadata, path.Join(d, f), "with", k.name) // if not already matched i := l.files[f] i.md = md @@ -407,7 +406,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.Log.OK("Ready to upload files") + to.log.Stage("Importing takeout assets") return fs.WalkDir(w, ".", func(name string, d fs.DirEntry, err error) error { if err != nil { return nil @@ -434,12 +433,12 @@ func (to *Takeout) passTwoWalk(ctx context.Context, w fs.FS, assetChan chan *bro } if f.md == nil { - to.jnl.AddEntry(name, logger.ERROR, "JSON File not found for this file") + to.log.AddEntry(log.ErrorLevel, logger.UpldERROR, name, "error", "can't find a JSON file for this file", "hint", "process all takeout files together") return nil } finfo, err := d.Info() if err != nil { - to.jnl.Log.Error("can't browse: %s", err) + to.log.AddEntry(log.ErrorLevel, logger.UpldERROR, name, "error", err) return nil } @@ -449,7 +448,7 @@ func (to *Takeout) passTwoWalk(ctx context.Context, w fs.FS, assetChan chan *bro year: f.md.PhotoTakenTime.Time().Year(), } if _, exists := to.uploaded[key]; exists { - to.jnl.AddEntry(name, logger.LocalDuplicate, "") + to.log.AddEntry(log.InfoLevel, logger.UpldLocalDuplicate, name) return nil } a := to.googleMDToAsset(f.md, key, w, name) diff --git a/browser/gp/testgp_bigread_test.go b/browser/gp/testgp_bigread_test.go index 62b8dc26..2f226b33 100644 --- a/browser/gp/testgp_bigread_test.go +++ b/browser/gp/testgp_bigread_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "github.com/charmbracelet/log" "github.com/simulot/immich-go/helpers/fshelper" "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/logger" @@ -19,10 +20,10 @@ func TestReadBigTakeout(t *testing.T) { if err != nil { panic(err) } + l := log.New(f) - l := logger.NewLogger(logger.Info, true, false) - l.SetWriter(f) - j := logger.NewJournal(l) + c := logger.NewCounters[logger.UpLdAction]() + lc := logger.NewLogAndCount[logger.UpLdAction](l, logger.SendNop, c) m, err := filepath.Glob("../../../test-data/full_takeout/*.zip") if err != nil { t.Error(err) @@ -30,7 +31,7 @@ func TestReadBigTakeout(t *testing.T) { } cnt := 0 fsyss, err := fshelper.ParsePath(m, true) - to, err := NewTakeout(context.Background(), j, immich.DefaultSupportedMedia, fsyss...) + to, err := NewTakeout(context.Background(), lc, immich.DefaultSupportedMedia, fsyss...) if err != nil { t.Error(err) return @@ -39,6 +40,6 @@ func TestReadBigTakeout(t *testing.T) { for range to.Browse(context.Background()) { cnt++ } - to.jnl.Report() + t.Log(to.log.String()) t.Logf("seen %d files", cnt) } diff --git a/browser/gp/testgp_test.go b/browser/gp/testgp_test.go index ebc5b297..3766d3e7 100644 --- a/browser/gp/testgp_test.go +++ b/browser/gp/testgp_test.go @@ -1,11 +1,15 @@ package gp import ( + "bytes" "context" + "io" + "os" "path" "reflect" "testing" + "github.com/charmbracelet/log" "github.com/kr/pretty" "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/logger" @@ -111,12 +115,21 @@ func TestBrowse(t *testing.T) { t.Error(fsys.err) return } + logBuffer := bytes.NewBuffer(nil) ctx := context.Background() + l := log.New(logBuffer) + cnt := logger.NewCounters[logger.UpLdAction]() + lc := logger.NewLogAndCount[logger.UpLdAction](l, logger.SendNop, cnt) - b, err := NewTakeout(ctx, logger.NewJournal(logger.NoLog{}), immich.DefaultSupportedMedia, fsys) + b, err := NewTakeout(ctx, lc, immich.DefaultSupportedMedia, fsys) if err != nil { t.Error(err) } + err = b.Prepare(ctx) + if err != nil { + t.Log(logBuffer.String()) + t.Error(err) + } results := []fileResult{} for a := range b.Browse(ctx) { @@ -127,6 +140,13 @@ func TestBrowse(t *testing.T) { if !reflect.DeepEqual(results, c.results) { t.Errorf("difference\n") pretty.Ldiff(t, c.results, results) + w, err := os.Create(c.name + ".log") + if err != nil { + t.Error(err) + } else { + _, _ = w.Write(logBuffer.Bytes()) + w.Close() + } } }) } @@ -185,7 +205,14 @@ func TestAlbums(t *testing.T) { t.Error(fsys.err) return } - b, err := NewTakeout(ctx, logger.NewJournal(logger.NoLog{}), immich.DefaultSupportedMedia, fsys) + l := log.New(io.Discard) + cnt := logger.NewCounters[logger.UpLdAction]() + lc := logger.NewLogAndCount[logger.UpLdAction](l, logger.SendNop, cnt) + b, err := NewTakeout(ctx, lc, immich.DefaultSupportedMedia, fsys) + if err != nil { + t.Error(err) + } + err = b.Prepare(ctx) if err != nil { t.Error(err) } diff --git a/cmd/album/album.go b/cmd/album/album.go index 5affb9cf..98325d67 100644 --- a/cmd/album/album.go +++ b/cmd/album/album.go @@ -9,7 +9,6 @@ import ( "strconv" "github.com/simulot/immich-go/cmd" - "github.com/simulot/immich-go/logger" "github.com/simulot/immich-go/ui" ) @@ -74,7 +73,7 @@ func deleteAlbum(ctx context.Context, common *cmd.SharedFlags, args []string) er if app.pattern.MatchString(al.AlbumName) { yes := app.AssumeYes if !yes { - app.Jnl.Log.OK("Delete album '%s'?", al.AlbumName) + app.Log.Printf("Delete album '%s'?", al.AlbumName) r, err := ui.ConfirmYesNo(ctx, "Proceed?", "n") if err != nil { return err @@ -84,12 +83,12 @@ func deleteAlbum(ctx context.Context, common *cmd.SharedFlags, args []string) er } } if yes { - app.Jnl.Log.MessageContinue(logger.OK, "Deleting album '%s'", al.AlbumName) + app.Log.Printf("Deleting album '%s'", al.AlbumName) err = app.Immich.DeleteAlbum(ctx, al.ID) if err != nil { return err } else { - app.Jnl.Log.MessageTerminate(logger.OK, "done") + app.Log.Print("done") } } } diff --git a/cmd/duplicate/duplicate.go b/cmd/duplicate/duplicate.go index da682650..5b2cb436 100644 --- a/cmd/duplicate/duplicate.go +++ b/cmd/duplicate/duplicate.go @@ -1,22 +1,28 @@ -/* -Check the list of photos to list and discard duplicates. -*/ package duplicate +// Check the list of photos to list and discard duplicates. + import ( "context" + "errors" "flag" + "fmt" "path" "sort" "strings" "time" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" "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" "github.com/simulot/immich-go/logger" - "github.com/simulot/immich-go/ui" + "github.com/simulot/immich-go/ui/duplicatepage" + "github.com/simulot/immich-go/ui/duplicatepage/duplicateitem" + "github.com/simulot/immich-go/ui/duplicatepage/duplicatelist" + "golang.org/x/sync/errgroup" ) type DuplicateCmd struct { @@ -27,16 +33,117 @@ type DuplicateCmd struct { IgnoreExtension bool // Ignore file extensions when checking for duplicates assetsByID map[string]*immich.Asset - assetsByBaseAndDate map[duplicateKey][]*immich.Asset + assetsByBaseAndDate map[DuplicateKey][]*immich.Asset + keys []DuplicateKey + page *tea.Program + ctx context.Context } -type duplicateKey struct { +type DuplicateKey struct { Date time.Time Name string Type string } -func NewDuplicateCmd(ctx context.Context, common *cmd.SharedFlags, args []string) (*DuplicateCmd, error) { +func DuplicateCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { + app, err := newDuplicateCmd(ctx, common, args) + if err != nil { + return err + } + + // Initialize the TU + app.page = tea.NewProgram(duplicatepage.NewDuplicatePage(app.Immich, app.Banner), tea.WithAltScreen()) + + // Launch the getAssets and duplicate detection in the background + errGrp := errgroup.Group{} + errGrp.Go(func() error { + err := app.getAssets() + if err != nil { + app.send(duplicatepage.DuplicateListError{Err: err}) + } + + return err + }) + + m, err := app.page.Run() + if err != nil { + return err + } + if m, ok := m.(duplicatepage.Model); ok { + return m.Err + } + + /* + + for _, k := range app.keys { + select { + case <-ctx.Done(): + return ctx.Err() + default: + l := app.assetsByBaseAndDate[k] + app.Log.Print("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 { + app.Log.Print(" 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 { + app.Log.Error("Can't get asset's albums: %s", err.Error()) + } else { + albums = append(albums, r...) + } + } else { + app.Log.Print(" 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") + if err != nil { + return err + } + if r == "y" { + yes = true + } + } + if yes { + err = app.Immich.DeleteAssets(ctx, assetsToDelete, false) + if err != nil { + app.Log.Error("Can't delete asset: %s", err.Error()) + } else { + app.Log.Print(" Asset removed") + for _, al := range albums { + app.Log.Print(" Update the album %s with the best copy", al.AlbumName) + _, err = app.Immich.AddAssetToAlbum(ctx, al.ID, []string{a.ID}) + if err != nil { + app.Log.Error("Can't delete asset: %s", err.Error()) + } + } + } + } + } + } + } + + } + */ + return nil +} + +func (app *DuplicateCmd) send(msg tea.Msg) { + if app.NoUI { + switch msg := msg.(type) { + case logger.MsgLog: + case logger.MsgStageSpinner: + fmt.Println(msg.Label) + } + } else { + app.page.Send(msg) + } +} + +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") @@ -44,7 +151,8 @@ func NewDuplicateCmd(ctx context.Context, common *cmd.SharedFlags, args []string SharedFlags: common, DateRange: validRange, assetsByID: map[string]*immich.Asset{}, - assetsByBaseAndDate: map[duplicateKey][]*immich.Asset{}, + assetsByBaseAndDate: map[DuplicateKey][]*immich.Asset{}, + ctx: ctx, } app.SharedFlags.SetFlags(cmd) @@ -64,113 +172,85 @@ func NewDuplicateCmd(ctx context.Context, common *cmd.SharedFlags, args []string return &app, err } -func DuplicateCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { - app, err := NewDuplicateCmd(ctx, common, args) +func (app *DuplicateCmd) getAssets() error { + statistics, err := app.Immich.GetServerStatistics(app.ctx) + totalOnImmich := statistics.Photos + statistics.Videos + received := 0 + dupCount := 0 if err != nil { return err } - dupCount := 0 - app.Jnl.Log.MessageContinue(logger.OK, "Get server's assets...") - err = app.Immich.GetAllAssetsWithFilter(ctx, func(a *immich.Asset) { - if a.IsTrashed { - return - } - if !app.DateRange.InRange(a.ExifInfo.DateTimeOriginal.Time) { - return - } - app.assetsByID[a.ID] = a - d := a.ExifInfo.DateTimeOriginal.Time.Round(time.Minute) - if app.IgnoreTZErrors { - d = time.Date(d.Year(), d.Month(), d.Day(), 0, d.Minute(), d.Second(), 0, time.UTC) - } - k := duplicateKey{ - Date: d, - Name: strings.ToUpper(a.OriginalFileName + path.Ext(a.OriginalPath)), - Type: a.Type, - } + done := errors.New("done") + app.send(logger.MsgLog{Message: "Get %d asset(s) from the server"}) - if app.IgnoreExtension { - k.Name = strings.TrimSuffix(k.Name, path.Ext(a.OriginalPath)) - } - l := app.assetsByBaseAndDate[k] - if len(l) > 0 { - dupCount++ + err = app.Immich.GetAllAssetsWithFilter(app.ctx, func(ctx context.Context, a *immich.Asset) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + received++ + if a.IsTrashed { + return nil + } + if !app.DateRange.InRange(a.ExifInfo.DateTimeOriginal.Time) { + return nil + } + app.assetsByID[a.ID] = a + d := a.ExifInfo.DateTimeOriginal.Time.Round(time.Minute) + if app.IgnoreTZErrors { + d = time.Date(d.Year(), d.Month(), d.Day(), 0, d.Minute(), d.Second(), 0, time.UTC) + } + k := DuplicateKey{ + Date: d, + Name: strings.ToUpper(a.OriginalFileName + path.Ext(a.OriginalPath)), + Type: a.Type, + } + + if app.IgnoreExtension { + k.Name = strings.TrimSuffix(k.Name, path.Ext(a.OriginalPath)) + } + l := app.assetsByBaseAndDate[k] + if len(l) > 0 { + dupCount++ + } + app.assetsByBaseAndDate[k] = append(l, a) + app.send(duplicatelist.DuplicateLoadingMsg{Total: totalOnImmich, Checked: received, Duplicated: dupCount}) + if received > 5000 { + return done + } } - app.assetsByBaseAndDate[k] = append(l, a) + return nil }) - if err != nil { + if err != nil && err != done { return err } - 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 { + // Get the duplicated sorted by date and name + app.keys = gen.MapFilterKeys(app.assetsByBaseAndDate, func(i []*immich.Asset) bool { return len(i) > 1 }) - sort.Slice(keys, func(i, j int) bool { - c := keys[i].Date.Compare(keys[j].Date) + sort.Slice(app.keys, func(i, j int) bool { + c := app.keys[i].Date.Compare(app.keys[j].Date) switch c { case -1: return true case +1: return false } - c = strings.Compare(keys[i].Name, keys[j].Name) - + c = strings.Compare(app.keys[i].Name, app.keys[j].Name) return c == -1 }) - for _, k := range keys { - select { - case <-ctx.Done(): - return ctx.Err() - default: - l := app.assetsByBaseAndDate[k] - 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 { - 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 { - app.Jnl.Log.Error("Can't get asset's albums: %s", err.Error()) - } else { - albums = append(albums, r...) - } - } else { - 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") - if err != nil { - return err - } - if r == "y" { - yes = true - } - } - if yes { - err = app.Immich.DeleteAssets(ctx, assetsToDelete, false) - if err != nil { - app.Jnl.Log.Error("Can't delete asset: %s", err.Error()) - } else { - app.Jnl.Log.OK(" Asset removed") - for _, al := range albums { - 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 { - app.Jnl.Log.Error("Can't delete asset: %s", err.Error()) - } - } - } - } - } - } - } + // Send the list to the TUI + list := []list.Item{} + for _, k := range app.keys { + list = append(list, duplicateitem.Group{ + Date: k.Date, + Name: k.Name, + Assets: app.assetsByBaseAndDate[k], + }) } + app.send(list) return nil } diff --git a/cmd/duplicate/tui.nogo b/cmd/duplicate/tui.nogo new file mode 100644 index 00000000..6858bc74 --- /dev/null +++ b/cmd/duplicate/tui.nogo @@ -0,0 +1,289 @@ +package duplicate + +import ( + "errors" + "fmt" + "path" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/simulot/immich-go/immich" + "github.com/simulot/immich-go/ui" +) + +type DuplicateModel struct { + receivedPct int + receivedDup int + list list.Model + ready bool + + app *DuplicateCmd + + width, height int + err error +} + +type ( + msgReceivePct int + msgDuplicate int + msgError struct { + Err error + } +) + +const bannerHeight = 6 + +var _ tea.Model = (*DuplicateModel)(nil) + +func NewDuplicateModel(app *DuplicateCmd, keys []DuplicateKey) DuplicateModel { + l := list.New([]list.Item{}, list.NewDefaultDelegate(), 80, 15) + l.KeyMap.NextPage = key.NewBinding( + key.WithKeys("l", "pgdown", "f", "d"), + key.WithHelp("l/pgdn", "next page"), + ) + l.KeyMap.PrevPage = key.NewBinding( + key.WithKeys("h", "pgup", "b", "u"), + key.WithHelp("h/pgup", "prev page"), + ) + m := DuplicateModel{ + app: app, + list: l, + } + m.adjustListTitle(false) + return m +} + +func (m DuplicateModel) Init() tea.Cmd { + m.list.SetSpinner(spinner.Dot) + return m.list.StartSpinner() +} + +func (m DuplicateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + // send the event to the table + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.list.SetWidth(m.width) + m.list.SetHeight(m.height - bannerHeight) + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.err = errors.New("interrupted by the user") + cmds = append(cmds, tea.Quit) + } + + case msgError: + m.err = msg.Err + cmds = append(cmds, tea.Quit) + + case msgReceivePct: + m.receivedPct = int(msg) + m.list = m.adjustListTitle(false) + case msgDuplicate: + m.receivedDup = int(msg) + m.list = m.adjustListTitle(false) + case []DuplicateKey: + // the table is ready + m.list, cmd = m.loadTable(msg) + m.ready = true + cmds = append(cmds, cmd) + + // case duplicateKey: + // // an item is highlighted + // m.sideList, cmd = m.selectKey(msg) + // cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) +} + +func (m DuplicateModel) View() string { + b := strings.Builder{} + row := 0 + if m.height > 10 { + b.WriteString(m.app.SharedFlags.Banner) + row += 5 + } + b.WriteString(m.list.View()) + _ = row + return b.String() +} + +// duplicateItem is a list item. +type duplicateItem struct { + key DuplicateKey // Duplicate key + assets []*immich.Asset // List of duplicates + keep int +} + +func (i duplicateItem) Title() string { + return fmt.Sprintf("%s %s", i.key.Date.Format("2006.01.02 15:04:05 Z07:00"), i.key.Name) +} + +func (i duplicateItem) Description() string { + b := strings.Builder{} + for j := range i.assets { + if j > 0 { + b.WriteString(", ") + } + b.WriteString("File:" + path.Base(i.assets[j].OriginalPath)) + b.WriteString(" Size:" + ui.FormatBytes(i.assets[j].ExifInfo.FileSizeInByte)) + if j == i.keep { + b.WriteString(" ✅") + } else { + b.WriteString(" 🗑") + } + } + return b.String() +} + +func (i duplicateItem) FilterValue() string { + return i.key.Name + i.key.Date.Format("2006.01.02 15:04:05 Z07:00") +} + +type itemDelegate struct { + list.DefaultDelegate + lastSelected int + itemStyle lipgloss.Style + selectedStyle lipgloss.Style +} + +// type msgSelected struct { +// idx int +// when time.Time +// } + +// func (d itemDelegate) Height() int { return d.DefaultDelegate.Height() } +// func (d itemDelegate) Spacing() int { return d.DefaultDelegate.Spacing() } +func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + ix := m.Index() + + switch msg := msg.(type) { //nolint:gocritic + // case msgSelected: + // if ix == msg.idx && time.Since(msg.when) > 500*time.Millisecond { + // } + + case tea.KeyMsg: + switch msg.String() { + case "left": + item, ok := m.SelectedItem().(duplicateItem) + if ok { + if item.keep > 0 { + item.keep-- + } else { + item.keep = len(item.assets) - 1 + } + m.SetItem(ix, item) + } + case "right": + ix := m.Index() + item, ok := m.SelectedItem().(duplicateItem) + if ok { + if item.keep < len(item.assets)-1 { + item.keep++ + } else { + item.keep = 0 + } + m.SetItem(ix, item) + } + } + } + cmds := []tea.Cmd{} // cmds := []tea.Cmd{d.detectSelectionChange(ix)} + if d.DefaultDelegate.UpdateFunc != nil { + cmds = append(cmds, d.DefaultDelegate.UpdateFunc(msg, m)) + } + return tea.Batch(cmds...) +} + +// func (d itemDelegate) detectSelectionChange(newIdx int) tea.Cmd { +// if d.lastSelected != newIdx { +// d.lastSelected = newIdx +// return sendMsg(msgSelected{ +// idx: newIdx, +// when: time.Now(), +// }) +// } +// return nil +// } + +// func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { +// i, ok := listItem.(duplicateItem) +// if !ok { +// return +// } + +// str := fmt.Sprintf("%d. %s", index+1, i) + +// fn := d.itemStyle.Render +// if index == m.Index() { +// fn = func(s ...string) string { +// return d.selectedStyle.Render(s) +// } +// } + +// fmt.Fprintf(w, fn(str)) +// } + +// loadTable with the result of the scan +func (m DuplicateModel) loadTable(keys []DuplicateKey) (list.Model, tea.Cmd) { //nolint:unparam + items := make([]list.Item, len(keys)) + for i, k := range keys { + l := m.app.assetsByBaseAndDate[k] + sort.Slice(l, func(i, j int) bool { return l[i].ExifInfo.FileSizeInByte < l[j].ExifInfo.FileSizeInByte }) + items[i] = duplicateItem{ + key: k, + assets: l, + keep: len(l) - 1, + } + } + m.list.SetItems(items) + m.list.SetDelegate(itemDelegate{ + DefaultDelegate: list.NewDefaultDelegate(), + lastSelected: -1, + }) + return m.list, nil +} + +// func (m DuplicateModel) selectKey(k duplicateKey) (list.Model, tea.Cmd) { //nolint:unparam +// m.selectedKey = k +// l := m.app.assetsByBaseAndDate[k] +// sort.Slice(l, func(i, j int) bool { return l[i].ExifInfo.FileSizeInByte < l[j].ExifInfo.FileSizeInByte }) +// items := make([]list.Item, len(l)) +// for i := range l { +// item := duplicateItem{ +// a: l[i], +// keepMe: i == len(l)-1, +// } +// items[i] = item +// } + +// m.sideList = list.New(items, list.NewDefaultDelegate(), 50, m.height-6) +// return m.sideList, nil +// } + +func (m DuplicateModel) adjustListTitle(done bool) list.Model { + if !done { + m.list.Title = fmt.Sprintf("Receiving asstets (%d%%), %d duplicates", m.receivedPct, m.receivedDup) + } else { + m.list.StopSpinner() + m.list.Title = "List of duplicates" + } + return m.list +} + +func sendMsg[T any](m T) tea.Cmd { + return func() tea.Msg { + return m + } +} diff --git a/cmd/metadata/metadata.go b/cmd/metadata/metadata.go index 856d9a73..ea835833 100644 --- a/cmd/metadata/metadata.go +++ b/cmd/metadata/metadata.go @@ -13,7 +13,6 @@ import ( "github.com/simulot/immich-go/helpers/myflag" "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/immich/metadata" - "github.com/simulot/immich-go/logger" ) type MetadataCmd struct { @@ -57,15 +56,15 @@ func MetadataCommand(ctx context.Context, common *cmd.SharedFlags, args []string if err != nil { return err } - app.Jnl.Log.OK("Connected to the immich's docker container at %q", app.DockerHost) + app.Log.Printf("Connected to the immich's docker container at %q", app.DockerHost) } - app.Jnl.Log.MessageContinue(logger.OK, "Get server's assets...") + app.Log.Print("Get server's assets...") list, err := app.Immich.GetAllAssets(ctx) if err != nil { return err } - app.Jnl.Log.MessageTerminate(logger.OK, " %d received", len(list)) + app.Log.Printf(" %d received", len(list)) type broken struct { a *immich.Asset @@ -114,18 +113,18 @@ func MetadataCommand(ctx context.Context, common *cmd.SharedFlags, args []string if b.fixable { fixable++ } - 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.Printf("%s, (%s %s): %s", b.a.OriginalPath, b.a.ExifInfo.Make, b.a.ExifInfo.Model, strings.Join(b.reason, ", ")) } - app.Jnl.Log.OK("%d broken assets", len(brockenAssets)) - app.Jnl.Log.OK("Among them, %d can be fixed with current settings", fixable) + app.Log.Printf("%d broken assets", len(brockenAssets)) + app.Log.Printf("Among them, %d can be fixed with current settings", fixable) if fixable == 0 { return nil } if app.DryRun { - app.Jnl.Log.OK("Dry-run mode. Exiting") - app.Jnl.Log.OK("use -dry-run=false after metadata command") + app.Log.Print("Dry-run mode. Exiting") + app.Log.Print("use -dry-run=false after metadata command") return nil } @@ -141,7 +140,7 @@ func MetadataCommand(ctx context.Context, common *cmd.SharedFlags, args []string continue } a := b.a - app.Jnl.Log.MessageContinue(logger.OK, "Uploading sidecar for %s... ", a.OriginalPath) + app.Log.Printf("Uploading sidecar for %s... ", a.OriginalPath) scContent, err := b.SideCar.Bytes() if err != nil { return err @@ -150,7 +149,7 @@ func MetadataCommand(ctx context.Context, common *cmd.SharedFlags, args []string if err != nil { return err } - app.Jnl.Log.MessageTerminate(logger.OK, "done") + app.Log.Print("done") } return nil } diff --git a/cmd/shared.go b/cmd/shared.go index ac071392..548251ba 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -9,12 +9,13 @@ import ( "os" "runtime" "strings" + "time" + "github.com/charmbracelet/log" "github.com/simulot/immich-go/helpers/configuration" "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 @@ -30,19 +31,24 @@ type SharedFlags struct { Debug bool // Enable the debug mode TimeZone string // Override default TZ SkipSSL bool // Skip SSL Verification + NoUI bool // Disable user interface Immich immich.ImmichInterface // Immich client - Jnl *logger.Journal // Program's logger LogFile string // Log file - out io.WriteCloser // the log writer + out io.Writer // the log writer + Log *log.Logger + Banner string } -func (app *SharedFlags) InitSharedFlags() { +func (app *SharedFlags) InitSharedFlags(banner string) { app.ConfigurationFile = configuration.DefaultFile() app.NoLogColors = runtime.GOOS == "windows" app.APITrace = false app.Debug = false app.SkipSSL = false + app.LogFile = "./immich-go " + time.Now().Format("2006-01-02 15-04-05") + ".log" + app.Banner = banner + app.NoUI = false } // SetFlag add common flags to a flagset @@ -59,6 +65,7 @@ func (app *SharedFlags) SetFlags(fs *flag.FlagSet) { fs.BoolFunc("debug", "enable debug messages", myflag.BoolFlagFn(&app.Debug, app.Debug)) 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, app.SkipSSL)) + fs.BoolFunc("no-ui", "Disable the user interface", myflag.BoolFlagFn(&app.NoUI, app.NoUI)) } func (app *SharedFlags) Start(ctx context.Context) error { @@ -71,33 +78,18 @@ func (app *SharedFlags) Start(ctx context.Context) error { 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.OpenFile(app.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o664) - if err != nil { - joinedErr = errors.Join(joinedErr, err) - } else { - app.Jnl.Log.SetWriter(f) - } - app.out = f - } - } + var err error - if app.LogLevel != "" { - logLevel, err := logger.StringToLevel(app.LogLevel) + if app.out == nil { + app.out, err = os.OpenFile(app.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o664) if err != nil { joinedErr = errors.Join(joinedErr, err) + } else { + app.Log.SetReportTimestamp(true) + app.Log.SetOutput(app.out) + app.Log.Print("\n" + app.Banner) } - 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 @@ -157,13 +149,21 @@ func (app *SharedFlags) Start(ctx context.Context) error { if err != nil { return err } - app.Jnl.Log.OK("Server status: OK") + app.Log.Print("Server status: OK") user, err := app.Immich.ValidateConnection(ctx) if err != nil { return err } - app.Jnl.Log.Info("Connected, user: %s", user.Email) + app.Log.Printf("Connected, user: %s", user.Email) + } + return nil +} + +func (app *SharedFlags) Close() error { + if closer, ok := app.out.(io.Closer); ok { + fmt.Println("Check the log file for details:", app.LogFile) + return closer.Close() } return nil } diff --git a/cmd/stack/stack.go b/cmd/stack/stack.go index 1842abd1..c4b3b726 100644 --- a/cmd/stack/stack.go +++ b/cmd/stack/stack.go @@ -10,7 +10,6 @@ import ( "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" "github.com/simulot/immich-go/ui" ) @@ -54,32 +53,38 @@ func NewStackCommand(ctx context.Context, common *cmd.SharedFlags, args []string } sb := stacking.NewStackBuilder(app.Immich.SupportedMedia()) - app.Jnl.Log.MessageContinue(logger.OK, "Get server's assets...") + app.Log.Print("Get server's assets...") assetCount := 0 - err = app.Immich.GetAllAssetsWithFilter(ctx, func(a *immich.Asset) { - if a.IsTrashed { - return - } - if !app.DateRange.InRange(a.ExifInfo.DateTimeOriginal.Time) { - return + err = app.Immich.GetAllAssetsWithFilter(ctx, func(ctx context.Context, a *immich.Asset) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if a.IsTrashed { + return nil + } + if !app.DateRange.InRange(a.ExifInfo.DateTimeOriginal.Time) { + return nil + } + assetCount += 1 + sb.ProcessAsset(a.ID, a.OriginalFileName+path.Ext(a.OriginalPath), a.ExifInfo.DateTimeOriginal.Time) } - assetCount += 1 - sb.ProcessAsset(a.ID, a.OriginalFileName+path.Ext(a.OriginalPath), a.ExifInfo.DateTimeOriginal.Time) + return nil }) if err != nil { return err } stacks := sb.Stacks() - app.Jnl.Log.MessageTerminate(logger.OK, " %d received, %d stack(s) possible", assetCount, len(stacks)) + app.Log.Printf(" %d received, %d stack(s) possible", assetCount, len(stacks)) for _, s := range stacks { - app.Jnl.Log.OK("Stack following images taken on %s", s.Date) + app.Log.Printf("Stack following images taken on %s", s.Date) cover := s.CoverID names := s.Names sort.Strings(names) for _, n := range names { - app.Jnl.Log.OK(" %s", n) + app.Log.Printf(" %s", n) } yes := app.AssumeYes if !app.AssumeYes { @@ -94,7 +99,7 @@ func NewStackCommand(ctx context.Context, common *cmd.SharedFlags, args []string if yes { err := app.Immich.StackAssets(ctx, cover, s.IDs) if err != nil { - app.Jnl.Log.Warning("Can't stack images: %s", err) + app.Log.Warn("Can't stack images: %s", err) } } } diff --git a/cmd/upload/e2e_upload_folder_test.go b/cmd/upload/e2e_upload_folder_test.go index 13103fe5..1f305da4 100644 --- a/cmd/upload/e2e_upload_folder_test.go +++ b/cmd/upload/e2e_upload_folder_test.go @@ -7,10 +7,12 @@ import ( "context" "errors" "fmt" + "io" "os" "testing" "time" + "github.com/charmbracelet/log" "github.com/joho/godotenv" "github.com/simulot/immich-go/cmd" "github.com/simulot/immich-go/immich" @@ -45,7 +47,6 @@ type testCase struct { } func runCase(t *testing.T, tc testCase) { - host := myEnv["IMMICH_HOST"] if host == "" { host = "http://localhost:2283" @@ -87,7 +88,7 @@ func runCase(t *testing.T, tc testCase) { } } - args := []string{"-server=" + host, "-key=" + key, "-log-file=" + tc.name + ".log"} + args := []string{"-no-ui", "-server=" + host, "-key=" + key, "-log-file=" + tc.name + ".log"} if tc.APITrace { args = append(args, "-api-trace=TRUE") @@ -97,6 +98,7 @@ func runCase(t *testing.T, tc testCase) { app := cmd.SharedFlags{ Immich: ic, + Log: log.New(io.Discard), } err = UploadCommand(ctx, &app, args) @@ -379,7 +381,8 @@ func Test_GP_MultiZip(t *testing.T) { name: "Test_Issue_128", args: []string{ "-google-photos", - myEnv["IMMICH_TESTFILES"] + "/google-photos/zip*.zip"}, + myEnv["IMMICH_TESTFILES"] + "/google-photos/zip*.zip", + }, resetImmich: true, expectError: false, APITrace: false, diff --git a/cmd/upload/tui.go b/cmd/upload/tui.go new file mode 100644 index 00000000..d056687a --- /dev/null +++ b/cmd/upload/tui.go @@ -0,0 +1,197 @@ +package upload + +import ( + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/log" + "github.com/simulot/immich-go/logger" +) + +type ( + msgQuit struct{ error } + msgReceiveAsset float64 + UploadModel struct { + // sub models + messages []logger.MsgLog + countersMdl UploadCountersModel + spinnerReceive spinner.Model + spinnerBrowser spinner.Model + + // + counters *logger.Counters[logger.UpLdAction] + receivedAssetPct float64 + spinnerBrowserLabel string + app *UpCmd + err error + width, height int + } +) + +var _ tea.Model = (*UploadModel)(nil) + +func NewUploadModel(app *UpCmd, c *logger.Counters[logger.UpLdAction]) UploadModel { + return UploadModel{ + counters: c, + countersMdl: NewUploadCountersModel(c), + spinnerReceive: spinner.New(spinner.WithSpinner(spinner.Points)), + spinnerBrowser: spinner.New(spinner.WithSpinner(spinner.Points)), + app: app, + } +} + +func (m UploadModel) Init() tea.Cmd { + return tea.Batch(cmdTick(), m.spinnerBrowser.Tick, m.spinnerReceive.Tick) +} + +func (m UploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.err = errors.New("interrupted by the user") + return m, tea.Quit + } + case msgQuit: + m.err = msg.error + return m, tea.Quit + case logger.MsgLog: + m.messages = append(m.messages, msg) + if len(m.messages) > m.height { + m.messages = slices.Delete(m.messages, 0, 1) + } + case msgReceiveAsset: + m.receivedAssetPct = float64(msg) + return m, nil + case msgTick: + return m, cmdTick() + case msgReceivingAssetDone: + m.receivedAssetPct = 2.0 + return m, nil + case logger.MsgStageSpinner: + m.spinnerBrowserLabel = msg.Label + return m, m.spinnerBrowser.Tick + case spinner.TickMsg: + var cmds []tea.Cmd + var cmd tea.Cmd + m.spinnerBrowser, cmd = m.spinnerBrowser.Update(msg) + cmds = append(cmds, cmd) + m.spinnerReceive, cmd = m.spinnerReceive.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + return m, nil +} + +func (m UploadModel) View() string { + b := strings.Builder{} + row := 0 + if m.height > 26 { + b.WriteString(m.app.SharedFlags.Banner) + row += 5 + } + if m.counters != nil { + b.WriteString(m.countersMdl.View()) + b.WriteRune('\n') + row += 19 + } + if m.receivedAssetPct > 0 && m.receivedAssetPct < 2.0 { + b.WriteString(m.spinnerReceive.View()) + b.WriteString(fmt.Sprintf(" Server's assets receiving (%d%%)", int(m.receivedAssetPct*100))) + b.WriteRune('\n') + row += 1 + } + if m.spinnerBrowserLabel != "" { + b.WriteString(m.spinnerBrowser.View()) + b.WriteString(" ") + b.WriteString(m.spinnerBrowserLabel) + b.WriteRune('\n') + row += 1 + } + if len(m.messages) > 0 { + remains := m.height - row + for i := max(len(m.messages)-remains, 0); i < len(m.messages); i++ { + if m.messages[i].Lvl != log.InfoLevel { + b.WriteString(m.messages[i].Lvl.String()) + b.WriteRune(' ') + } + b.WriteString(m.messages[i].Message) + b.WriteRune('\n') + row++ + } + } + return b.String() +} + +// UploadCountersModel is a tea.Model for upload counters +type UploadCountersModel struct { + counters *logger.Counters[logger.UpLdAction] +} + +var _ tea.Model = (*UploadCountersModel)(nil) + +func NewUploadCountersModel(counters *logger.Counters[logger.UpLdAction]) UploadCountersModel { + return UploadCountersModel{ + counters: counters, + } +} + +func (m UploadCountersModel) View() string { + c := m.counters.GetCounters() + if c == nil { + return "" + } + + sb := strings.Builder{} + checkFiles := c[logger.UpldScannedImage] + c[logger.UpldScannedVideo] + handledFiles := c[logger.UpldLocalDuplicate] + c[logger.UpldServerDuplicate] + c[logger.UpldServerBetter] + c[logger.UpldUploaded] + c[logger.UpldUpgraded] + c[logger.UpldServerError] + c[logger.UpldNotSelected] + + sb.WriteString("-------------------------------------------------------------------\n") + sb.WriteString(fmt.Sprintf("%6d discovered files in the input\n", c[logger.UpldDiscoveredFile])) + sb.WriteString(fmt.Sprintf("%6d photos\n", c[logger.UpldScannedImage])) + sb.WriteString(fmt.Sprintf("%6d videos\n", c[logger.UpldScannedVideo])) + sb.WriteString(fmt.Sprintf("%6d metadata files\n", c[logger.UpldMetadata])) + sb.WriteString(fmt.Sprintf("%6d files with metadata\n", c[logger.UpldAssociatedMetadata])) + sb.WriteString(fmt.Sprintf("%6d discarded files\n", c[logger.UpldDiscarded])) + sb.WriteString("\n-------------------------------------------------------------------\n") + + sb.WriteString(fmt.Sprintf("%6d asset(s) received from the server\n", c[logger.UpldReceived])) + sb.WriteString(fmt.Sprintf("%6d not selected\n", c[logger.UpldNotSelected])) + sb.WriteString(fmt.Sprintf("%6d uploaded files on the server\n", c[logger.UpldUploaded])) + sb.WriteString(fmt.Sprintf("%6d upgraded files on the server\n", c[logger.UpldUpgraded])) + sb.WriteString(fmt.Sprintf("%6d files already on the server\n", c[logger.UpldServerDuplicate])) + sb.WriteString(fmt.Sprintf("%6d discarded files because duplicated in the input\n", c[logger.UpldLocalDuplicate])) + sb.WriteString(fmt.Sprintf("%6d discarded files because server has a better image\n", c[logger.UpldServerBetter])) + sb.WriteString(fmt.Sprintf("%6d errors when uploading\n", c[logger.UpldServerError])) + + sb.WriteString(fmt.Sprintf("%6d handled total (difference %d)\n", handledFiles, checkFiles-handledFiles)) + return sb.String() +} + +// Init implements the tea.Model +func (m UploadCountersModel) Init() tea.Cmd { + return nil +} + +// Update implements the tea.Model +func (m *UploadCountersModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +type msgTick time.Time + +func cmdTick() tea.Cmd { + return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { + return msgTick(t) + }) +} + +type msgReceivingAssetDone struct{} diff --git a/cmd/upload/upload.go b/cmd/upload/upload.go index fe0a10b4..c9e9c4b6 100644 --- a/cmd/upload/upload.go +++ b/cmd/upload/upload.go @@ -13,6 +13,8 @@ import ( "strings" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/log" "github.com/google/uuid" "github.com/simulot/immich-go/browser" "github.com/simulot/immich-go/browser/files" @@ -25,12 +27,22 @@ import ( "github.com/simulot/immich-go/immich" "github.com/simulot/immich-go/immich/metadata" "github.com/simulot/immich-go/logger" + "golang.org/x/sync/errgroup" ) +/* + TODO: + browser should't report non fatal errors + Add timeouts to http clients + deprecate ForceSidecar + pass supported medida to googlephotobrowser +*/ + type UpCmd struct { *cmd.SharedFlags // shared flags and immich client - fsys []fs.FS // pseudo file system to browse + args []string + fsyss []fs.FS // pseudo file system to browse GooglePhotos bool // For reading Google Photos takeout files Delete bool // Delete original file after import @@ -64,6 +76,12 @@ type UpCmd struct { mediaCount int // Count of media on the source updateAlbums map[string]map[string]any // track immich albums changes stacks *stacking.StackBuilder + page *tea.Program + counters *logger.Counters[logger.UpLdAction] + lc *logger.LogAndCount[logger.UpLdAction] + ctx context.Context + browser browser.Browser + send logger.Sender } func NewUpCmd(ctx context.Context, common *cmd.SharedFlags, args []string) (*UpCmd, error) { @@ -73,6 +91,7 @@ func NewUpCmd(ctx context.Context, common *cmd.SharedFlags, args []string) (*UpC app := UpCmd{ SharedFlags: common, updateAlbums: map[string]map[string]any{}, + ctx: ctx, } app.SharedFlags.SetFlags(cmd) @@ -161,93 +180,179 @@ func NewUpCmd(ctx context.Context, common *cmd.SharedFlags, args []string) (*UpC } app.BrowserConfig.Validate() + app.args = cmd.Args() - err = app.SharedFlags.Start(ctx) + return &app, err +} + +func UploadCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { + app, err := NewUpCmd(ctx, common, args) if err != nil { - return nil, err + return err } + defer func() { + _ = fshelper.CloseFSs(app.fsyss) + }() - app.fsys, err = fshelper.ParsePath(cmd.Args(), app.GooglePhotos) + // Get the list of files / folders to scan + app.fsyss, err = fshelper.ParsePath(app.args, app.GooglePhotos) if err != nil { - return nil, err + return err } - if app.CreateStacks || app.StackBurst || app.StackJpgRaws { - app.stacks = stacking.NewStackBuilder(app.Immich.SupportedMedia()) - } - app.Jnl.Log.OK("Ask for server's assets...") - var list []*immich.Asset - err = app.Immich.GetAllAssetsWithFilter(ctx, func(a *immich.Asset) { - if a.IsTrashed { - return - } - list = append(list, a) - }) + // Get common flags whatever their position before or after the upload command + err = app.SharedFlags.Start(ctx) if err != nil { - return nil, err + return err } - app.Jnl.Log.OK("%d asset(s) received", len(list)) - app.AssetIndex = &AssetIndex{ - assets: list, + if app.CreateStacks || app.StackBurst || app.StackJpgRaws { + app.stacks = stacking.NewStackBuilder(app.Immich.SupportedMedia()) } - app.AssetIndex.ReIndex() + app.counters = logger.NewCounters[logger.UpLdAction]() - return &app, err -} + // Initialize the TUI model + if !app.SharedFlags.NoUI { + app.page = tea.NewProgram(NewUploadModel(app, app.counters), tea.WithAltScreen()) + app.send = app.page.Send + } else { + app.send = app.sendNoUI + } -func UploadCommand(ctx context.Context, common *cmd.SharedFlags, args []string) error { - app, err := NewUpCmd(ctx, common, args) + app.lc = logger.NewLogAndCount[logger.UpLdAction](app.Log, app.send, app.counters) + + switch { + case app.GooglePhotos: + app.browser, err = app.ReadGoogleTakeOut(ctx, app.fsyss) + default: + app.browser, err = app.ExploreLocalFolder(ctx, app.fsyss) + } if err != nil { return err } - defer func() { - _ = fshelper.CloseFSs(app.fsys) - }() - return app.Run(ctx, app.fsys) -} + // Sequence of actions + fullGrp := errgroup.Group{} + fullGrp.Go(func() error { + initGrp := errgroup.Group{} + initGrp.Go(app.getAssets) + initGrp.Go(app.prepare) + err := initGrp.Wait() + if err != nil { + app.page.Send(msgQuit{err}) + return err + } + err = app.browse() + app.send(msgQuit{err}) + return err + }) + + if !app.SharedFlags.NoUI { + // Run the TUI + m, err := app.page.Run() + if err != nil { + return err + } + + err = fullGrp.Wait() + if err != nil { + return err + } + app.page.Wait() + if m, ok := m.(UploadModel); ok { + report := m.countersMdl.View() + defer func() { + app.SharedFlags.Log.Print(m.countersMdl.View()) + fmt.Println(report) + }() + return m.err + } + } else { + return fullGrp.Wait() + } -func (app *UpCmd) journalAsset(a *browser.LocalAssetFile, action logger.Action, comment ...string) { - app.Jnl.AddEntry(a.FileName, action, comment...) + return nil } -func (app *UpCmd) Run(ctx context.Context, fsyss []fs.FS) error { - var browser browser.Browser - var err error +func (app *UpCmd) sendNoUI(msg tea.Msg) { + if !app.SharedFlags.NoUI { + app.page.Send(msg) + return + } - switch { - case app.GooglePhotos: - app.Jnl.Log.Message(logger.OK, "Browsing google take out archive...") - browser, err = app.ReadGoogleTakeOut(ctx, fsyss) - default: - app.Jnl.Log.Message(logger.OK, "Browsing folder(s)...") - browser, err = app.ExploreLocalFolder(ctx, fsyss) + switch msg := msg.(type) { + case logger.MsgLog: + if msg.Lvl != log.InfoLevel { + fmt.Print(msg.Lvl.String(), " ") + } + fmt.Println(msg.Message) + case logger.MsgStageSpinner: + fmt.Println(msg.Label) } +} +func (app *UpCmd) getAssets() error { + app.lc.Print("Get Server Statistics") + statistics, err := app.Immich.GetServerStatistics(app.ctx) if err != nil { - app.Jnl.Log.Message(logger.Error, err.Error()) return err } - app.Jnl.Log.Message(logger.OK, "Done.") - assetChan := browser.Browse(ctx) -assetLoop: - for { + app.lc.Printf("Receiving %d asset(s) from the server", statistics.Photos+statistics.Videos) + totalOnImmich := float64(statistics.Photos + statistics.Videos) + received := 0 + + var list []*immich.Asset + err = app.Immich.GetAllAssetsWithFilter(app.ctx, func(ctx context.Context, a *immich.Asset) error { select { case <-ctx.Done(): return ctx.Err() + default: + received++ + app.counters.Add(logger.UpldReceived) + app.send(msgReceiveAsset(float64(received) / totalOnImmich)) + if a.IsTrashed { + return nil + } + list = append(list, a) + } + return nil + }) + if err != nil { + return err + } + + app.send(msgReceivingAssetDone{}) + app.AssetIndex = &AssetIndex{ + assets: list, + } + app.AssetIndex.ReIndex() + return err +} + +func (app *UpCmd) prepare() error { + return app.browser.Prepare(app.ctx) +} + +func (app *UpCmd) browse() error { + var err error + assetChan := app.browser.Browse(app.ctx) +assetLoop: + for { + select { + case <-app.ctx.Done(): + return app.ctx.Err() case a, ok := <-assetChan: if !ok { break assetLoop } if a.Err != nil { - app.journalAsset(a, logger.ERROR, a.Err.Error()) + app.lc.AddEntry(log.ErrorLevel, logger.UpldERROR, a.FileName, "error", a.Err) } else { - err = app.handleAsset(ctx, a) + err = app.handleAsset(app.ctx, a) if err != nil { - app.journalAsset(a, logger.ERROR, err.Error()) + app.lc.AddEntry(log.ErrorLevel, logger.UpldERROR, a.FileName, "error", a.Err) } } } @@ -256,7 +361,7 @@ assetLoop: if app.CreateStacks { stacks := app.stacks.Stacks() if len(stacks) > 0 { - app.Jnl.Log.OK("Creating stacks") + app.send(logger.MsgStageSpinner{Label: "Creating stacks"}) nextStack: for _, s := range stacks { switch { @@ -265,11 +370,11 @@ assetLoop: case !app.StackJpgRaws && s.StackType == stacking.StackRawJpg: continue nextStack } - app.Jnl.Log.OK(" Stacking %s...", strings.Join(s.Names, ", ")) + app.lc.AddEntry(log.InfoLevel, logger.UpldStack, s.Names[0], "files", s.Names[1:]) if !app.DryRun { - err = app.Immich.StackAssets(ctx, s.CoverID, s.IDs) + err = app.Immich.StackAssets(app.ctx, s.CoverID, s.IDs) if err != nil { - app.Jnl.Log.Warning("Can't stack images: %s", err) + app.lc.Error("Can't stack images", "error", err) } } } @@ -277,10 +382,9 @@ assetLoop: } if app.CreateAlbums || app.CreateAlbumAfterFolder || (app.KeepPartner && app.PartnerAlbum != "") || app.ImportIntoAlbum != "" { - app.Jnl.Log.OK("Managing albums") - err = app.ManageAlbums(ctx) + err = app.ManageAlbums(app.ctx) if err != nil { - app.Jnl.Log.Error(err.Error()) + app.lc.Error("Can't manage albums", "error", err) err = nil } } @@ -290,18 +394,16 @@ assetLoop: for _, da := range app.deleteServerList { ids = append(ids, da.ID) } - err := app.DeleteServerAssets(ctx, ids) + err = app.DeleteServerAssets(app.ctx, ids) if err != nil { - return fmt.Errorf("can't delete server's assets: %w", err) + app.lc.Error("Can't removing duplicates", "error", err) + err = nil } } if len(app.deleteLocalList) > 0 { err = app.DeleteLocalAssets() } - - app.Jnl.Report() - return err } @@ -319,42 +421,42 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er // } ext := path.Ext(a.FileName) if app.BrowserConfig.ExcludeExtensions.Exclude(ext) { - app.journalAsset(a, logger.NotSelected, "extension excluded") + app.lc.AddEntry(log.InfoLevel, logger.UpldNotSelected, a.FileName, "reason", "extension excluded") return nil } if !app.BrowserConfig.SelectExtensions.Include(ext) { - app.journalAsset(a, logger.NotSelected, "extension not selected") + app.lc.AddEntry(log.InfoLevel, logger.UpldNotSelected, a.FileName, "reason", "extension not selected") return nil } if !app.KeepPartner && a.FromPartner { - app.journalAsset(a, logger.NotSelected, "partners asset excluded") + app.lc.AddEntry(log.InfoLevel, logger.UpldNotSelected, a.FileName, "reason", "partner's assets are excluded") return nil } if !app.KeepTrashed && a.Trashed { - app.journalAsset(a, logger.NotSelected, "trashed asset excluded") + app.lc.AddEntry(log.InfoLevel, logger.UpldNotSelected, a.FileName, "reason", "trashed assets are excluded") return nil } if app.ImportFromAlbum != "" && !app.isInAlbum(a, app.ImportFromAlbum) { - app.journalAsset(a, logger.NotSelected, "asset excluded because not from the required album") + app.lc.AddEntry(log.InfoLevel, logger.UpldNotSelected, a.FileName, "reason", "asset not in selected album") return nil } if app.DiscardArchived && a.Archived { - app.journalAsset(a, logger.NotSelected, "asset excluded because archives are discarded") + app.lc.AddEntry(log.InfoLevel, logger.UpldNotSelected, a.FileName, "reason", "archived assets are excluded") return nil } if app.DateRange.IsSet() { d := a.DateTaken if d.IsZero() { - app.journalAsset(a, logger.NotSelected, "asset excluded because the date of capture is unknown and a date range is given") + app.lc.AddEntry(log.InfoLevel, logger.UpldNotSelected, a.FileName, "reason", "date of capture is unknown and a date range is given") return nil } if !app.DateRange.InRange(d) { - app.journalAsset(a, logger.NotSelected, "asset excluded because the date of capture out of the date range") + app.lc.AddEntry(log.InfoLevel, logger.UpldNotSelected, a.FileName, "reason", "date of capture is out of the date range") return nil } } @@ -365,8 +467,6 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er }) } - app.Jnl.Log.DebugObject("handleAsset: LocalAssetFile=", a) - advice, err := app.AssetIndex.ShouldUpload(a) if err != nil { return err @@ -380,10 +480,10 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er app.deleteLocalList = append(app.deleteLocalList, a) } case SmallerOnServer: - app.journalAsset(a, logger.Upgraded, advice.Message) + app.lc.AddEntry(log.InfoLevel, logger.UpldUpgraded, a.FileName, "reason", advice.Message) // add the superior asset into albums of the original asset for _, al := range advice.ServerAsset.Albums { - app.journalAsset(a, logger.INFO, willBeAddedToAlbum+al.AlbumName) + app.lc.AddEntry(log.InfoLevel, logger.UpldINFO, a.FileName, "reason", willBeAddedToAlbum+al.AlbumName) a.AddAlbum(browser.LocalAlbum{Name: al.AlbumName}) } ID, err = app.UploadAsset(ctx, a) @@ -394,25 +494,24 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er } } case SameOnServer: - // Set add the server asset into albums determined locally if !advice.ServerAsset.JustUploaded { - app.journalAsset(a, logger.ServerDuplicate, advice.Message) + app.lc.AddEntry(log.InfoLevel, logger.UpldServerDuplicate, a.FileName, "reason", advice.Message) } else { - app.journalAsset(a, logger.LocalDuplicate) + app.lc.AddEntry(log.InfoLevel, logger.UpldLocalDuplicate, a.FileName, "reason", "File already handled") } ID = advice.ServerAsset.ID if app.CreateAlbums { for _, al := range a.Albums { - app.journalAsset(a, logger.INFO, willBeAddedToAlbum+al.Name) + app.lc.AddEntry(log.InfoLevel, logger.UpldINFO, a.FileName, "reason", willBeAddedToAlbum+al.Name) app.AddToAlbum(advice.ServerAsset.ID, app.albumName(al)) } } if app.ImportIntoAlbum != "" { - app.journalAsset(a, logger.INFO, willBeAddedToAlbum+app.ImportIntoAlbum) + app.lc.AddEntry(log.InfoLevel, logger.UpldINFO, a.FileName, "reason", willBeAddedToAlbum+app.ImportIntoAlbum) app.AddToAlbum(advice.ServerAsset.ID, app.ImportIntoAlbum) } if app.PartnerAlbum != "" && a.FromPartner { - app.journalAsset(a, logger.INFO, willBeAddedToAlbum+app.PartnerAlbum) + app.lc.AddEntry(log.InfoLevel, logger.UpldINFO, a.FileName, "reason", willBeAddedToAlbum+app.PartnerAlbum) app.AddToAlbum(advice.ServerAsset.ID, app.PartnerAlbum) } if !advice.ServerAsset.JustUploaded { @@ -423,17 +522,17 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er return nil } case BetterOnServer: - app.journalAsset(a, logger.ServerBetter, advice.Message) + app.lc.AddEntry(log.InfoLevel, logger.UpldServerBetter, a.FileName, "reason", advice.Message) ID = advice.ServerAsset.ID // keep the server version but update albums if app.CreateAlbums { for _, al := range a.Albums { - app.journalAsset(a, logger.INFO, willBeAddedToAlbum+al.Name) + app.lc.AddEntry(log.InfoLevel, logger.UpldINFO, a.FileName, "reason", willBeAddedToAlbum+al.Name) app.AddToAlbum(advice.ServerAsset.ID, app.albumName(al)) } } if app.PartnerAlbum != "" && a.FromPartner { - app.journalAsset(a, logger.INFO, willBeAddedToAlbum+app.PartnerAlbum) + app.lc.AddEntry(log.InfoLevel, logger.UpldINFO, a.FileName, "reason", willBeAddedToAlbum+app.PartnerAlbum) app.AddToAlbum(advice.ServerAsset.ID, app.PartnerAlbum) } } @@ -468,15 +567,13 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er Names := []string{} for _, al := range albums { Name := app.albumName(al) - app.Jnl.Log.DebugObject("Will be added to the album: ", al) - if app.GooglePhotos && Name == "" { continue } Names = append(Names, Name) } if len(Names) > 0 { - app.journalAsset(a, logger.Album, strings.Join(Names, ", ")) + app.lc.AddEntry(log.InfoLevel, logger.UpldAlbum, a.FileName, "files", Names) for _, n := range Names { app.AddToAlbum(ID, n) } @@ -492,10 +589,9 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er if !app.DryRun && shouldUpdate { _, err := app.Immich.UpdateAsset(ctx, ID, a) if err != nil { - app.Jnl.Log.Error("can't update the asset '%s': ", err) + app.lc.AddEntry(log.ErrorLevel, logger.UpldServerError, "error", fmt.Errorf("can't update the asset '%w': ", err)) } } - return nil } @@ -510,11 +606,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.Jnl, app.Immich.SupportedMedia(), fsyss...) + return gp.NewTakeout(ctx, app.lc, app.Immich.SupportedMedia(), fsyss...) } func (app *UpCmd) ExploreLocalFolder(ctx context.Context, fsyss []fs.FS) (browser.Browser, error) { - b, err := files.NewLocalFiles(ctx, app.Jnl, fsyss...) + b, err := files.NewLocalFiles(ctx, app.lc, fsyss...) if err != nil { return nil, err } @@ -546,18 +642,18 @@ func (app *UpCmd) UploadAsset(ctx context.Context, a *browser.LocalAssetFile) (s resp.ID = uuid.NewString() } if err != nil { - app.journalAsset(a, logger.ServerError, err.Error()) + app.lc.AddEntry(log.ErrorLevel, logger.UpldServerError, a.FileName, "error", err) return "", err } if !resp.Duplicate { - app.journalAsset(a, logger.Uploaded, a.Title) + app.lc.AddEntry(log.InfoLevel, logger.UpldUploaded, a.FileName, "name", a.Title) app.AssetIndex.AddLocalAsset(a, resp.ID) app.mediaUploaded += 1 if app.CreateStacks { app.stacks.ProcessAsset(resp.ID, a.FileName, a.DateTaken) } } else { - app.journalAsset(a, logger.ServerDuplicate, "already on the server") + app.lc.AddEntry(log.InfoLevel, logger.UpldServerDuplicate, a.FileName, "reason", "already on the server") } return resp.ID, nil @@ -586,34 +682,33 @@ func (app *UpCmd) AddToAlbum(id string, album string) { } func (app *UpCmd) DeleteLocalAssets() error { - app.Jnl.Log.OK("%d local assets to delete.", len(app.deleteLocalList)) + app.page.Printf("%d local assets to delete.", len(app.deleteLocalList)) for _, a := range app.deleteLocalList { if !app.DryRun { - app.Jnl.Log.Warning("delete file %q", a.Title) + app.page.Printf("delete file %q", a.Title) err := a.Remove() if err != nil { return err } } else { - app.Jnl.Log.Warning("file %q not deleted, dry run mode", a.Title) + app.page.Printf("file %q not deleted, dry run mode", a.Title) } } return nil } func (app *UpCmd) DeleteServerAssets(ctx context.Context, ids []string) error { - app.Jnl.Log.Warning("%d server assets to delete.", len(ids)) - + app.lc.AddEntry(log.InfoLevel, logger.UpldDeleteServerAssets, "", "ids", ids) if !app.DryRun { err := app.Immich.DeleteAssets(ctx, ids, false) return err } - 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 { + app.send(logger.MsgStageSpinner{Label: "Managing albums"}) if len(app.updateAlbums) > 0 { serverAlbums, err := app.Immich.GetAllAlbums(ctx) if err != nil { @@ -624,26 +719,22 @@ func (app *UpCmd) ManageAlbums(ctx context.Context) error { for _, sal := range serverAlbums { if sal.AlbumName == album { found = true + app.lc.AddEntry(log.InfoLevel, logger.UpldCreateAlbum, album, "ids", gen.MapKeys(list)) if !app.DryRun { - 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) - } - added := 0 - for _, r := range rr { - if r.Success { - added++ + app.lc.AddEntry(log.ErrorLevel, logger.UpldCreateAlbum, album, "error", err, "ids", gen.MapKeys(list)) + } else { + added := 0 + for _, r := range rr { + if r.Success { + added++ + } + if !r.Success && r.Error != "duplicate" { + app.lc.AddEntry(log.ErrorLevel, logger.UpldCreateAlbum, album, "error", err) + } } - if !r.Success && r.Error != "duplicate" { - app.Jnl.Log.Warning("%s: %s", r.ID, r.Error) - } - } - if added > 0 { - app.Jnl.Log.OK("%d asset(s) added to the album %q", added, album) } - } else { - app.Jnl.Log.OK("Update album %s skipped - dry run mode", album) } } } @@ -651,15 +742,12 @@ func (app *UpCmd) ManageAlbums(ctx context.Context) error { continue } if list != nil { + app.send(logger.MsgLog{Lvl: log.InfoLevel, Message: fmt.Sprintf("Create the album %s", album)}) if !app.DryRun { - app.Jnl.Log.OK("Create the album %s", album) - _, 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) + app.lc.AddEntry(log.ErrorLevel, logger.UpldCreateAlbum, album, "error", err) } - } else { - 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 705052e5..45bb46f0 100644 --- a/cmd/upload/upload_test.go +++ b/cmd/upload/upload_test.go @@ -3,23 +3,23 @@ package upload import ( "cmp" "context" - "errors" - "io/fs" + "image" + "io" "reflect" "slices" "testing" + "github.com/charmbracelet/log" "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" ) type stubIC struct{} -func (c *stubIC) GetAllAssetsWithFilter(context.Context, func(*immich.Asset)) error { +func (c *stubIC) GetAllAssetsWithFilter(context.Context, func(context.Context, *immich.Asset) error) error { return nil } @@ -89,6 +89,10 @@ func (c *stubIC) SupportedMedia() immich.SupportedMedia { return immich.DefaultSupportedMedia } +func (c *stubIC) GetAssetThumbnail(ctx context.Context, id string) (image.Image, error) { + return nil, nil +} + type icCatchUploadsAssets struct { stubIC @@ -139,6 +143,7 @@ func TestUpload(t *testing.T) { expectedAssets: []string{"PXL_20231006_063000139.jpg"}, expectedAlbums: map[string][]string{}, }, + { name: "Simple file in an album", args: []string{ @@ -460,6 +465,7 @@ func TestUpload(t *testing.T) { // "Google Photos/Photos from 2023/DSC_0238(1).JPG", // }, // }, + } for _, tc := range testCases { @@ -467,24 +473,22 @@ func TestUpload(t *testing.T) { ic := &icCatchUploadsAssets{ albums: map[string][]string{}, } - log := logger.NoLog{} + log := log.New(io.Discard) ctx := context.Background() serv := cmd.SharedFlags{ - Immich: ic, - Jnl: logger.NewJournal(&log), + Immich: ic, + Log: log, + LogFile: "/dev/null", } - app, err := NewUpCmd(ctx, &serv, tc.args) + err := UploadCommand(ctx, &serv, append([]string{"-no-ui"}, tc.args...)) if err != nil { t.Errorf("can't instantiate the UploadCmd: %s", err) return } - for _, fsys := range app.fsys { - err = errors.Join(app.Run(ctx, []fs.FS{fsys})) - } - if (tc.expectedErr && err == nil) || (!tc.expectedErr && err != nil) { + if tc.expectedErr == (err == nil) { t.Errorf("unexpected error condition: %v,%s", tc.expectedErr, err) return } diff --git a/docs/releases.md b/docs/releases.md index efb724c4..ff9323c3 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,5 +1,47 @@ # Release notes +## Release next + +### A better user interface for the upload command + +#### A "modern" terminal unser interface + +![](./screen.gif) + +I'm using the wonderful library provided by [charm.sh](https://charm.sh/) to compose the page and display the progression on the work. +You can quit the program with ctrl+c or q keys. + +For those who are using immich-go in batch, this can be disabled with the option: `-no-ui` + + +#### A better log files + +The log now gives the precise reason for discarding files: + +```log +2024-04-07 16:26:29 INFO Discarded file="Takeout/Google\u00a0Photos/Year 2022/PXL_20221224_175124414.MP" reason="useless file" +2024-04-07 16:26:29 INFO Not selected because of options file="Takeout/Google\u00a0Photos/Corbeille/PXL_20231017_190807437.LONG_EXPOSURE-02.ORIGINA.jpg" reason="trashed assets are excluded" + +``` + + + +#### This release introduce some breaking changes: + +- The log file is now generated each time. The default file name is ./immich-go-{YYMMDD HHMMSS}.log +- The log-level can't be change + + +### Improvement: [#195](https://github.com/simulot/immich-go/issues/195) Rethink the user interactions with the CLI application #195 + + + +#### Use Bubble Tea library to provide a modern TUI (Terminal User Interface) + + +### API KEY self provisioning +When the server and the API keys aren't given on the command line, immich-go ask the user if he wants to get a key from a server, and saves it the configuration file. + ## Release 0.13.0 ### Fix [[#211](https://github.com/simulot/immich-go/issues/211)] immich-go appears to retain/cache an API key diff --git a/docs/screen.gif b/docs/screen.gif new file mode 100644 index 00000000..de6eac1b Binary files /dev/null and b/docs/screen.gif differ diff --git a/go.mod b/go.mod index e0283713..ab13d81a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,10 @@ module github.com/simulot/immich-go go 1.21 require ( + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.10.0 // indirect + github.com/charmbracelet/log v0.4.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/kr/pretty v0.3.1 @@ -10,15 +14,36 @@ require ( github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e - github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 + golang.org/x/sync v0.7.0 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/huh v0.3.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.13.6 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect golang.org/x/crypto v0.13.0 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/image v0.15.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.13.0 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 3576163a..8c8d2ed5 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,30 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE= +github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA= +github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= +github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -14,8 +35,33 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/melbahja/goph v1.4.0 h1:z0PgDbBFe66lRYl3v5dGb9aFgPy0kotuQ37QOwSQFqs= github.com/melbahja/goph v1.4.0/go.mod h1:uG+VfK2Dlhk+O32zFrRlc3kYKTlV6+BtvPWd/kK7U68= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -26,20 +72,25 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e h1:51xcRlSMBU5rhM9KahnJGfEsBPVPz3182TgFRowA8yY= github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= -github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 h1:OXcKh35JaYsGMRzpvFkLv/MEyPuL49CThT1pZ8aSml4= -github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -48,6 +99,10 @@ golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= +golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -57,6 +112,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -67,20 +124,27 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/helpers/asciimage/images.go b/helpers/asciimage/images.go new file mode 100644 index 00000000..7ab77485 --- /dev/null +++ b/helpers/asciimage/images.go @@ -0,0 +1,28 @@ +package asciimage + +import ( + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "os" + + _ "golang.org/x/image/webp" +) + +// var UnsupportedImageFormat = errors.New("unsupported image format") + +func LoadFile(name string) (image.Image, error) { + r, err := os.Open(name) + if err != nil { + return nil, err + } + defer r.Close() + return LoadReader(r) +} + +func LoadReader(r io.Reader) (image.Image, error) { + i, _, err := image.Decode(r) + return i, err +} diff --git a/helpers/asciimage/utf8renderer.go b/helpers/asciimage/utf8renderer.go new file mode 100644 index 00000000..8592b96c --- /dev/null +++ b/helpers/asciimage/utf8renderer.go @@ -0,0 +1,160 @@ +package asciimage + +/* + +Notes: + the package nfnt is archived since long + +Credit + Andrew Albers https://github.com/Zebbeni + - QuarterBlock rendering + - AvgColor +*/ + +import ( + "image" + "math" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/lucasb-eyer/go-colorful" + "github.com/nfnt/resize" +) + +const ( + charRatio = 0.5 +) + +// Utf8Renderer +// Render the image i as an ascii box of height x width chars box +// + +func Utf8Renderer(input image.Image, height, width int) (string, error) { + imgW, imgH := float32(input.Bounds().Dx()), float32(input.Bounds().Dy()) + fitHeight := float32(width) * (imgH / imgW) * float32(charRatio) + fitWidth := (float32(height) * (imgW / imgH)) / float32(charRatio) + if fitHeight > float32(height) { + width = int(fitWidth) + } else { + height = int(fitHeight) + } + + sizedImage := resize.Resize(uint(width)*2, uint(height)*2, input, resize.NearestNeighbor) + + // sizedImage := image.NewRGBA(image.Rect(0, 0, width*2, height*2)) + // draw.NearestNeighbor.Scale(sizedImage, sizedImage.Rect, input, input.Bounds(), draw.Over, nil) + + rendered := strings.Builder{} + for y := 0; y < height*2; y += 2 { + for x := 0; x < width*2; x += 2 { + // r1 r2 + // r3 r4 + r1, _ := colorful.MakeColor(sizedImage.At(x, y)) + r2, _ := colorful.MakeColor(sizedImage.At(x+1, y)) + r3, _ := colorful.MakeColor(sizedImage.At(x, y+1)) + r4, _ := colorful.MakeColor(sizedImage.At(x+1, y+1)) + + // pick the block, fg and bg color with the lowest total difference + // convert the colors to ansi, render the block and add it at row[x] + r, fg, bg := getBlock(quarterBlockFunctions, r1, r2, r3, r4) + + pFg, _ := colorful.MakeColor(fg) + pBg, _ := colorful.MakeColor(bg) + + style := lipgloss.NewStyle().Foreground(lipgloss.Color(pFg.Hex())).Background(lipgloss.Color(pBg.Hex())) + rendered.WriteString(style.Render(string(r))) + } + rendered.WriteRune('\n') + } + return rendered.String(), nil +} + +// find the best block character and foreground and background colors to match +// a set of 4 pixels. return +func getBlock(fns map[rune]blockFunctions, r1, r2, r3, r4 colorful.Color) (r rune, fg, bg colorful.Color) { + minDist := 100.0 + for bRune, bFunc := range fns { + f, b, dist := bFunc(r1, r2, r3, r4) + if dist < minDist { + minDist = dist + r, fg, bg = bRune, f, b + } + } + return +} + +// Evaluate block foreground and background colors and return the error made +type blockFunctions func(r1, r2, r3, r4 colorful.Color) (colorful.Color, colorful.Color, float64) + +var quarterBlockFunctions = map[rune]blockFunctions{ + '▀': calcTop, + '▐': calcRight, + '▞': calcDiagonal, + '▖': calcBotLeft, + '▘': calcTopLeft, + '▝': calcTopRight, + '▗': calcBotRight, +} + +func calcTop(r1, r2, r3, r4 colorful.Color) (colorful.Color, colorful.Color, float64) { + if r1.R == 0 && r1.G == 0 && r1.B == 0 && (r3.R != 0 || r3.G != 0 || r3.B != 0) { + r1.R = r1.G + } + fg, fDist := avgCol(r1, r2) + bg, bDist := avgCol(r3, r4) + return fg, bg, fDist + bDist +} + +func calcRight(r1, r2, r3, r4 colorful.Color) (colorful.Color, colorful.Color, float64) { + fg, fDist := avgCol(r2, r4) + bg, bDist := avgCol(r1, r3) + return fg, bg, fDist + bDist +} + +func calcDiagonal(r1, r2, r3, r4 colorful.Color) (colorful.Color, colorful.Color, float64) { + fg, fDist := avgCol(r2, r3) + bg, bDist := avgCol(r1, r4) + return fg, bg, fDist + bDist +} + +func calcBotLeft(r1, r2, r3, r4 colorful.Color) (colorful.Color, colorful.Color, float64) { + fg, fDist := avgCol(r3) + bg, bDist := avgCol(r1, r2, r4) + return fg, bg, fDist + bDist +} + +func calcTopLeft(r1, r2, r3, r4 colorful.Color) (colorful.Color, colorful.Color, float64) { + fg, fDist := avgCol(r1) + bg, bDist := avgCol(r2, r3, r4) + return fg, bg, fDist + bDist +} + +func calcTopRight(r1, r2, r3, r4 colorful.Color) (colorful.Color, colorful.Color, float64) { + fg, fDist := avgCol(r2) + bg, bDist := avgCol(r1, r3, r4) + return fg, bg, fDist + bDist +} + +func calcBotRight(r1, r2, r3, r4 colorful.Color) (colorful.Color, colorful.Color, float64) { + fg, fDist := avgCol(r4) + bg, bDist := avgCol(r1, r2, r3) + return fg, bg, fDist + bDist +} + +func avgCol(colors ...colorful.Color) (colorful.Color, float64) { + rSum, gSum, bSum := 0.0, 0.0, 0.0 + for _, col := range colors { + rSum += col.R + gSum += col.G + bSum += col.B + } + count := float64(len(colors)) + avg := colorful.Color{R: rSum / count, G: gSum / count, B: bSum / count} + + // compute sum of squares + totalDist := 0.0 + for _, col := range colors { + totalDist += math.Pow(col.DistanceCIEDE2000(avg), 2) + } + return avg, totalDist +} diff --git a/immich/call.go b/immich/call.go index fbc6abee..4028327c 100644 --- a/immich/call.go +++ b/immich/call.go @@ -225,6 +225,13 @@ func setAcceptJSON() serverRequestOption { } } +func setAcceptType(accepted string) serverRequestOption { + return func(sc *serverCall, req *http.Request) error { + req.Header.Add("Accept", accepted) + return nil + } +} + func setAPIKey() serverRequestOption { return func(sc *serverCall, req *http.Request) error { req.Header.Set("x-api-key", sc.ic.key) @@ -274,6 +281,22 @@ func responseJSON[T any](object *T) serverResponseOption { } } +func responseBodyHandler(fn func(r io.Reader) error) serverResponseOption { + return func(sc *serverCall, resp *http.Response) error { + if resp != nil { + if resp.Body != nil { + defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent { + return nil + } + err := fn(resp.Body) + return err + } + } + return errors.New("can't decode nil response") + } +} + /* func responseAccumulateJSON[T any](acc *[]T) serverResponseOption { return func(sc *serverCall, resp *http.Response) error { diff --git a/immich/immich.go b/immich/immich.go index 03416766..d55d37f3 100644 --- a/immich/immich.go +++ b/immich/immich.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "image" "sync" "time" @@ -25,7 +26,7 @@ type ImmichInterface interface { GetAllAssets(ctx context.Context) ([]*Asset, error) AddAssetToAlbum(context.Context, string, []string) ([]UpdateAlbumResult, error) UpdateAssets(ctx context.Context, IDs []string, isArchived bool, isFavorite bool, latitude float64, longitude float64, removeParent bool, stackParentID string) error - GetAllAssetsWithFilter(context.Context, func(*Asset)) error + GetAllAssetsWithFilter(context.Context, func(context.Context, *Asset) error) error AssetUpload(context.Context, *browser.LocalAssetFile) (AssetResponse, error) DeleteAssets(context.Context, []string, bool) error @@ -37,6 +38,7 @@ type ImmichInterface interface { StackAssets(ctx context.Context, cover string, IDs []string) error SupportedMedia() SupportedMedia + GetAssetThumbnail(ctx context.Context, id string) (image.Image, error) } type UnsupportedMedia struct { diff --git a/immich/metadata.go b/immich/metadata.go index 3b704586..2f222ded 100644 --- a/immich/metadata.go +++ b/immich/metadata.go @@ -27,7 +27,7 @@ func (sb *searchMetadataGetAllBody) setPage(p int) { sb.Page = p } -func (ic *ImmichClient) callSearchMetadata(ctx context.Context, req searchMetadataBody, filter func(*Asset)) error { +func (ic *ImmichClient) callSearchMetadata(ctx context.Context, req searchMetadataBody, filter func(context.Context, *Asset) error) error { req.setPage(1) for { resp := searchMetadataResponse{} @@ -37,7 +37,10 @@ func (ic *ImmichClient) callSearchMetadata(ctx context.Context, req searchMetada } for _, a := range resp.Assets.Items { - filter(a) + err = filter(ctx, a) + if err != nil { + return nil + } } if resp.Assets.NextPage == 0 { @@ -52,14 +55,17 @@ func (ic *ImmichClient) GetAllAssets(ctx context.Context) ([]*Asset, error) { var assets []*Asset req := searchMetadataGetAllBody{Page: 1, WithExif: true, IsVisible: true} - err := ic.callSearchMetadata(ctx, &req, func(asset *Asset) { assets = append(assets, asset) }) + err := ic.callSearchMetadata(ctx, &req, func(ctx context.Context, asset *Asset) error { + assets = append(assets, asset) + return nil + }) if err != nil { return nil, err } return assets, nil } -func (ic *ImmichClient) GetAllAssetsWithFilter(ctx context.Context, filter func(*Asset)) error { +func (ic *ImmichClient) GetAllAssetsWithFilter(ctx context.Context, filter func(context.Context, *Asset) error) error { req := searchMetadataGetAllBody{Page: 1, WithExif: true, IsVisible: true} return ic.callSearchMetadata(ctx, &req, filter) } diff --git a/immich/thumbnail.go b/immich/thumbnail.go new file mode 100644 index 00000000..a8799017 --- /dev/null +++ b/immich/thumbnail.go @@ -0,0 +1,18 @@ +package immich + +import ( + "context" + "image" + "io" +) + +func (ic *ImmichClient) GetAssetThumbnail(ctx context.Context, id string) (image.Image, error) { + var img image.Image + err := ic.newServerCall(ctx, "getAssetThumbnail").do(get("/asset/thumbnail/"+id, setAcceptType("application/octet-stream")), + responseBodyHandler(func(r io.Reader) error { + var err error + img, _, err = image.Decode(r) + return err + })) + return img, err +} diff --git a/logger/counters.go b/logger/counters.go new file mode 100644 index 00000000..7cba8ca8 --- /dev/null +++ b/logger/counters.go @@ -0,0 +1,46 @@ +package logger + +import ( + "cmp" + "maps" + "sync" + "time" +) + +type Measure interface { + cmp.Ordered + String() string +} + +// Counters implements a bunch of Measures +type Counters[M Measure] struct { + l sync.RWMutex + counters map[M]int + canary int64 +} + +func NewCounters[M Measure]() *Counters[M] { + c := Counters[M]{ + counters: map[M]int{}, + canary: time.Now().UnixMilli(), + } + return &c +} + +func (c *Counters[M]) Add(m M) { + c.l.Lock() + c.counters[m] = c.counters[m] + 1 + c.l.Unlock() +} + +func (c *Counters[M]) GetCounters() map[M]int { + if c == nil { + return nil + } + c.l.RLock() + defer c.l.RUnlock() + + r := map[M]int{} + maps.Copy(r, c.counters) + return r +} diff --git a/logger/journal.go b/logger/journal.go deleted file mode 100644 index 0dbac89f..00000000 --- a/logger/journal.go +++ /dev/null @@ -1,98 +0,0 @@ -package logger - -import ( - "strings" - "sync" -) - -type Journal struct { - mut sync.Mutex - counts map[Action]int - Log Logger -} - -type Action string - -const ( - DiscoveredFile Action = "File" - ScannedImage Action = "Scanned image" - ScannedVideo Action = "Scanned video" - Discarded Action = "Discarded" - Uploaded Action = "Uploaded" - Upgraded Action = "Server's asset upgraded" - ERROR Action = "Error" - LocalDuplicate Action = "Local duplicate" - ServerDuplicate Action = "Server has photo" - Stacked Action = "Stacked" - ServerBetter Action = "Server's asset is better" - Album Action = "Added to an album" - LivePhoto Action = "Live photo" - FailedVideo Action = "Failed video" - Unsupported Action = "File type not supported" - Metadata Action = "Metadata files" - AssociatedMetadata Action = "Associated with metadata" - INFO Action = "Info" - NotSelected Action = "Not selected because options" - ServerError Action = "Server error" -) - -func NewJournal(log Logger) *Journal { - return &Journal{ - // files: map[string]Entries{}, - Log: log, - counts: map[Action]int{}, - } -} - -func (j *Journal) AddEntry(file string, action Action, comment ...string) { - if j == nil { - return - } - c := strings.Join(comment, ", ") - if j.Log != nil { - switch action { - case ERROR, ServerError: - j.Log.Error("%-25s: %s: %s", action, file, c) - case DiscoveredFile: - j.Log.Debug("%-25s: %s: %s", action, file, c) - case Uploaded: - j.Log.OK("%-25s: %s: %s", action, file, c) - default: - j.Log.Info("%-25s: %s: %s", action, file, c) - } - } - j.mut.Lock() - j.counts[action]++ - if action == Upgraded { - j.counts[Uploaded]-- - } - j.mut.Unlock() -} - -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.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.Log.OK("%6d input total (difference %d)", checkFiles, j.counts[DiscoveredFile]-checkFiles) - j.Log.OK("--------------------------------------------------------") - - 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.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 b7f48fe2..ed33bfd7 100644 --- a/logger/log.go +++ b/logger/log.go @@ -1,276 +1,58 @@ package logger import ( - "bytes" - "encoding/json" - "fmt" - "io" "os" - "strings" + "time" - "github.com/ttacon/chalk" + "github.com/charmbracelet/log" ) -type Level int - -const ( - Fatal Level = iota - Error - Warning - OK - Info - Debug -) - -func (l Level) String() string { - switch l { - case Fatal: - return "Fatal" - case Error: - return "Error" - case Warning: - return "Warning" - case OK: - return "OK" - case Info: - return "Info" - case Debug: - return "Debug" - default: - return fmt.Sprintf("Log Level %d", l) - } -} - -func StringToLevel(s string) (Level, error) { - s = strings.ToLower(s) - for l := Fatal; l <= Debug; l++ { - if strings.ToLower(l.String()) == s { - return l, nil - } - } - return Error, fmt.Errorf("unknown log level: %s", s) -} - -var colorLevel = map[Level]string{ - Fatal: chalk.Red.String(), - Error: chalk.Red.String(), - Warning: chalk.Yellow.String(), - OK: chalk.Green.String(), - Info: chalk.White.String(), - Debug: chalk.Cyan.String(), -} - -type Log struct { - needCR bool - needSpace bool - displayLevel Level - noColors bool - colorStrings map[Level]string - debug bool - out io.WriteCloser -} - -func NewLogger(displayLevel Level, noColors bool, debug bool) *Log { - l := Log{ - displayLevel: displayLevel, - noColors: noColors, - colorStrings: map[Level]string{}, - debug: debug, - out: os.Stdout, - } - if !noColors { - l.colorStrings = colorLevel - } - return &l -} - -func (l *Log) Close() error { - if l.out != os.Stdout { - return l.out.Close() - } - return nil -} - -func (l *Log) SetDebugFlag(flag bool) { - l.debug = flag -} - -func (l *Log) SetLevel(level Level) { - l.displayLevel = level -} - -func (l *Log) SetColors(flag bool) { - if l.out != os.Stdout { - flag = false - } - if flag { - l.colorStrings = colorLevel - l.noColors = false - } else { - l.colorStrings = map[Level]string{} - l.noColors = true - } -} - -func (l *Log) SetWriter(w io.WriteCloser) { - if l != nil && w != nil { - l.out = w - l.noColors = true - l.colorStrings = map[Level]string{} - } -} - -func (l *Log) Debug(f string, v ...any) { - if l == nil || l.out == nil { - return - } - l.Message(Debug, f, v...) -} - -type DebugObject interface { - DebugObject() any -} - -func (l *Log) DebugObject(name string, v any) { - if l == nil || !l.debug { - return - } - if l.out == nil { - return - } - if d, ok := v.(DebugObject); ok { - v = d.DebugObject() - } - b := bytes.NewBuffer(nil) - enc := json.NewEncoder(b) - enc.SetIndent("", " ") - err := enc.Encode(v) +type Logger interface { + Debug(msg interface{}, keyvals ...interface{}) + Debugf(format string, args ...interface{}) + Error(msg interface{}, keyvals ...interface{}) + Errorf(format string, args ...interface{}) + Info(msg interface{}, keyvals ...interface{}) + Infof(format string, args ...interface{}) + Print(msg interface{}, keyvals ...interface{}) + Printf(format string, args ...interface{}) + Log(level log.Level, msg interface{}, keyvals ...interface{}) + Logf(level log.Level, format string, args ...interface{}) +} + +func NewLogger(logLevel string, noColors bool) *log.Logger { + styles := log.DefaultStyles() + // styles.Levels[log.ErrorLevel] = lipgloss.NewStyle(). + // SetString("ERROR "). + // Padding(0, 1, 0, 1). + // Background(lipgloss.Color("196")). // Light Red + // Foreground(lipgloss.Color("15")) // White + // styles.Levels[log.WarnLevel] = lipgloss.NewStyle(). + // SetString("WARNING"). + // Padding(0, 1, 0, 1). + // Background(lipgloss.Color("214")). // Kind of Orange + // Foreground(lipgloss.Color("0")) // Black + // styles.Levels[log.WarnLevel] = lipgloss.NewStyle(). + // SetString("INFO "). + // Padding(0, 1, 0, 1). + // Background(lipgloss.Color("70")). // Kind of Dark green + // Foreground(lipgloss.Color("0")) // Black + // styles.Levels[log.WarnLevel] = lipgloss.NewStyle(). + // SetString("DEBUG "). + // Padding(0, 1, 0, 1). + // Background(lipgloss.Color("128")). // Kind of Purple + // Foreground(lipgloss.Color("15")) // White + + lv, err := log.ParseLevel(logLevel) if err != nil { - l.Error("can't display object %s: %s", name, err) - return - } - if l.needCR { - fmt.Println() - l.needCR = false - } - l.needSpace = false - fmt.Fprint(l.out, l.colorStrings[Debug]) - fmt.Fprintf(l.out, "%s:\n%s", name, b.String()) - if !l.noColors { - fmt.Fprint(l.out, chalk.ResetColor) - } - fmt.Fprintln(l.out) -} - -func (l *Log) Info(f string, v ...any) { - if l == nil || l.out == nil { - fmt.Printf(f, v...) - fmt.Println() - return + lv = log.InfoLevel } - l.Message(Info, f, v...) -} -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...) -} + l := log.NewWithOptions(os.Stderr, log.Options{ + TimeFormat: time.DateTime, + Level: lv, + }) + l.SetStyles(styles) -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...) -} - -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...) -} - -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...) -} - -func (l *Log) Message(level Level, f string, v ...any) { - if l == nil || l.out == nil { - return - } - if level > l.displayLevel { - return - } - if l.needCR { - fmt.Fprintln(l.out) - l.needCR = false - } - l.needSpace = false - fmt.Fprint(l.out, l.colorStrings[level]) - fmt.Fprintf(l.out, f, v...) - if !l.noColors { - fmt.Fprint(l.out, chalk.ResetColor) - } - fmt.Fprintln(l.out) -} - -func (l *Log) Progress(level Level, f string, v ...any) { - if l == nil || l.out == nil { - return - } - if level > l.displayLevel { - return - } - fmt.Fprintf(l.out, "\r\033[2K"+f, v...) - l.needCR = true -} - -func (l *Log) MessageContinue(level Level, f string, v ...any) { - if l == nil || l.out == nil { - return - } - if level > l.displayLevel { - return - } - if l.needCR { - fmt.Fprintln(l.out) - l.needCR = false - } - if l.needSpace { - fmt.Print(" ") - } - fmt.Fprint(l.out, l.colorStrings[level]) - fmt.Fprintf(l.out, f, v...) - l.needSpace = true - l.needCR = false -} - -func (l *Log) MessageTerminate(level Level, f string, v ...any) { - if l == nil || l.out == nil { - return - } - if level > l.displayLevel { - return - } - fmt.Fprint(l.out, l.colorStrings[level]) - fmt.Fprintf(l.out, f, v...) - if !l.noColors { - fmt.Fprint(l.out, chalk.ResetColor) - } - fmt.Fprintln(l.out) - l.needSpace = false - l.needCR = false + return l } diff --git a/logger/logAndCount.go b/logger/logAndCount.go new file mode 100644 index 00000000..60784b6e --- /dev/null +++ b/logger/logAndCount.go @@ -0,0 +1,104 @@ +package logger + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/log" +) + +type Sender func(msg tea.Msg) + +// LogAndCount decorate the log.Logger and provide the AddEntry function to +// log events in a log.Logger +// send those events to a tea.Program + +type LogAndCount[M Measure] struct { + l *log.Logger + c *Counters[M] + send Sender +} + +type MsgLog struct { + Lvl log.Level + Message string + KeyVals []interface{} +} + +type MsgStageSpinner struct { + Label string +} + +func NewLogAndCount[M Measure](l *log.Logger, sender Sender, c *Counters[M]) *LogAndCount[M] { + return &LogAndCount[M]{ + l: l, + c: c, + send: sender, + } +} + +func (lc LogAndCount[M]) AddEntry(lvl log.Level, counter M, file string, keyval ...interface{}) { + lc.c.Add(counter) + keyvals := append([]interface{}{"file", file}, keyval...) + lc.l.Log(lvl, counter.String(), keyvals...) + + // Send errors and warnings to the tea.Program event loop + lc.send(MsgLog{Lvl: lvl, Message: counter.String() + " file:" + file, KeyVals: keyval}) +} + +func (lc LogAndCount[M]) Stage(label string) { + lc.l.Print(label) + lc.send(MsgStageSpinner{Label: label}) +} + +// Implements some Log functions to display errors and log everything + +func (lc LogAndCount[M]) Print(msg interface{}, keyvals ...interface{}) { + lc.l.Print(msg, keyvals...) + lc.send(MsgLog{Lvl: log.InfoLevel, Message: fmt.Sprint(msg), KeyVals: keyvals}) +} + +func (lc LogAndCount[M]) Printf(format string, args ...interface{}) { + lc.l.Printf(format, args...) + lc.send(MsgLog{Lvl: log.InfoLevel, Message: fmt.Sprintf(format, args...)}) +} + +func (lc LogAndCount[M]) Debug(msg interface{}, keyvals ...interface{}) { + lc.l.Debug(msg, keyvals...) +} + +func (lc LogAndCount[M]) Debugf(format string, args ...interface{}) { + lc.l.Debugf(format, args...) +} + +func (lc LogAndCount[M]) Error(msg interface{}, keyvals ...interface{}) { + lc.l.Error(msg, keyvals...) + lc.send(MsgLog{Lvl: log.ErrorLevel, Message: fmt.Sprint(msg)}) +} + +func (lc LogAndCount[M]) Errorf(format string, args ...interface{}) { + lc.l.Error(format, args...) + lc.send(MsgLog{Lvl: log.ErrorLevel, Message: fmt.Sprintf(format, args...)}) +} + +func (lc LogAndCount[M]) Warn(msg interface{}, keyvals ...interface{}) { + lc.l.Warn(msg, keyvals...) + lc.send(MsgLog{Lvl: log.WarnLevel, Message: fmt.Sprint(msg)}) +} + +func (lc LogAndCount[M]) Warnf(format string, args ...interface{}) { + lc.l.Debug(format, args...) + lc.send(MsgLog{Lvl: log.WarnLevel, Message: fmt.Sprintf(format, args...)}) +} + +func (lc LogAndCount[M]) String() string { + b := strings.Builder{} + for c, v := range lc.c.counters { + b.WriteString(fmt.Sprintf("%s: %d\n", c, v)) + } + return b.String() +} + +func SendNop(tea.Msg) { +} diff --git a/logger/logger.go b/logger/logger.go deleted file mode 100644 index e4421203..00000000 --- a/logger/logger.go +++ /dev/null @@ -1,21 +0,0 @@ -package logger - -import "io" - -type Logger interface { - Debug(f string, v ...any) - DebugObject(name string, v any) - Info(f string, v ...any) - OK(f string, v ...any) - Warning(f string, v ...any) - Error(f string, v ...any) - Fatal(f string, v ...any) - Message(level Level, f string, v ...any) - 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 deleted file mode 100644 index 753c3394..00000000 --- a/logger/nologger.go +++ /dev/null @@ -1,21 +0,0 @@ -package logger - -import "io" - -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/logger/uploadJnl.go b/logger/uploadJnl.go new file mode 100644 index 00000000..4acaf3d0 --- /dev/null +++ b/logger/uploadJnl.go @@ -0,0 +1,55 @@ +package logger + +type UpLdAction int + +const ( + UpldDiscoveredFile UpLdAction = iota // "File" + UpldScannedImage // "Scanned image" + UpldScannedVideo // "Scanned video" + UpldDiscarded // "Discarded" + UpldUploaded // "Uploaded" + UpldUpgraded // "Server's asset upgraded" + UpldERROR // "Error" + UpldLocalDuplicate // "Local duplicate" + UpldServerDuplicate // "Server has photo" + UpldStacked // "Stacked" + UpldServerBetter // "Server's asset is better" + UpldAlbum // "Added to an album" + UpldMetadata // "Metadata files" + UpldAssociatedMetadata // "Associated with metadata" + UpldINFO // "Info" + UpldNotSelected // "Not selected because of options" + UpldServerError // "Server error" + UpldReceived // "Asset received from the server", + UpldStack // "Stack assets" + UpldCreateAlbum // "Create/Update album" + UpldDeleteServerAssets //"Delete server's assets" +) + +var _uploadActionStrings = map[UpLdAction]string{ + UpldDiscoveredFile: "File discovered", + UpldScannedImage: "Scanned image", + UpldScannedVideo: "Scanned video", + UpldDiscarded: "Discarded", + UpldUploaded: "Uploaded", + UpldUpgraded: "Server's asset upgraded", + UpldERROR: "Error", + UpldLocalDuplicate: "Local duplicate", + UpldServerDuplicate: "Server has photo", + UpldStacked: "Stacked", + UpldServerBetter: "Server's asset is better", + UpldAlbum: "Added to an album", + UpldMetadata: "Metadata files", + UpldAssociatedMetadata: "Associated with metadata", + UpldINFO: "Info", + UpldNotSelected: "Not selected because of options", + UpldServerError: "Server error", + UpldReceived: "Asset received from the server", + UpldStack: "Stack assets", + UpldCreateAlbum: "Create/Update album", + UpldDeleteServerAssets: "Delete server's assets", +} + +func (m UpLdAction) String() string { + return _uploadActionStrings[m] +} diff --git a/main.go b/main.go index a2dc3c97..47628cc2 100644 --- a/main.go +++ b/main.go @@ -10,11 +10,9 @@ import ( "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/logger" + "github.com/simulot/immich-go/ui" ) var ( @@ -25,7 +23,8 @@ var ( func main() { var err error - fmt.Printf("immich-go %s, commit %s, built at %s\n", version, commit, date) + fmt.Println() + fmt.Println(ui.Banner.ToString(fmt.Sprintf("%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()) @@ -52,14 +51,14 @@ func main() { } func Run(ctx context.Context) error { - log := logger.NewLogger(logger.OK, true, false) - defer log.Close() + log := logger.NewLogger("OK", true) + + app := cmd.SharedFlags{} + defer app.Close() - app := cmd.SharedFlags{ - Jnl: logger.NewJournal(log), - } fs := flag.NewFlagSet("main", flag.ExitOnError) - app.InitSharedFlags() + app.InitSharedFlags(ui.Banner.ToString(fmt.Sprintf("%s, commit %s, built at %s\n", version, commit, date))) + app.Log = log app.SetFlags(fs) err := fs.Parse(os.Args[1:]) @@ -81,18 +80,21 @@ func Run(ctx context.Context) error { err = upload.UploadCommand(ctx, &app, fs.Args()[1:]) case "duplicate": err = duplicate.DuplicateCommand(ctx, &app, fs.Args()[1:]) - case "metadata": - err = metadata.MetadataCommand(ctx, &app, fs.Args()[1:]) - case "stack": - err = stack.NewStackCommand(ctx, &app, fs.Args()[1:]) - case "tool": - err = tool.CommandTool(ctx, &app, fs.Args()[1:]) + /* + case "metadata": + err = metadata.MetadataCommand(ctx, &app, fs.Args()[1:]) + case "stack": + err = stack.NewStackCommand(ctx, &app, fs.Args()[1:]) + case "tool": + err = tool.CommandTool(ctx, &app, fs.Args()[1:]) + */ default: err = fmt.Errorf("unknown command: %q", cmd) } if err != nil { - log.Error(err.Error()) + log.Error(err) + fmt.Println(err) } return err } diff --git a/ui/banner.go b/ui/banner.go new file mode 100644 index 00000000..c66f718e --- /dev/null +++ b/ui/banner.go @@ -0,0 +1,28 @@ +package ui + +import "strings" + +type banner []string + +// Banner Ascii art +// Generator : http://patorjk.com/software/taag-v1/ +// Font: Three point +var Banner = banner{ + ". _ _ _ _ . _|_ __ _ _ ", + "|| | || | ||(_| | (_|(_)", + " _) ", +} + +// ToString generate a string with new lines and place the given text on the latest line +func (b banner) ToString(text string) string { + sb := strings.Builder{} + for i := range b { + sb.WriteString(b[i]) + if i == len(b)-1 && text != "" { + sb.WriteString(" ") + sb.WriteString(text) + } + sb.WriteRune('\n') + } + return sb.String() +} diff --git a/ui/duplicatepage/duplicatePage.go b/ui/duplicatepage/duplicatePage.go new file mode 100644 index 00000000..2123f494 --- /dev/null +++ b/ui/duplicatepage/duplicatePage.go @@ -0,0 +1,100 @@ +package duplicatepage + +import ( + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/simulot/immich-go/immich" + "github.com/simulot/immich-go/ui/duplicatepage/duplicateitem" + "github.com/simulot/immich-go/ui/duplicatepage/duplicatelist" +) + +type currentView int + +const ( + groupView currentView = iota + itemView +) + +func NewDuplicatePage(immich immich.ImmichInterface, banner string) Model { + m := Model{ + immich: immich, + banner: banner, + currentView: groupView, + + groupList: duplicatelist.NewListModel([]list.Item{}, 50, 50), + } + + return m +} + +/* +Handle the list of duplicated assets found in Immich +based on lipgloss List +*/ + +type Model struct { + immich immich.ImmichInterface // Immich client + banner string + currentView currentView + + completed bool + groupList duplicatelist.Model + groupModel duplicateitem.Model + width, height int + + Err error +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + // keep the underlying list updated + switch m.currentView { + case groupView: + m.groupList, cmd = m.groupList.Update(msg) + case itemView: + m.groupModel, cmd = m.groupModel.Update(msg) + } + + if cmd != nil { + cmds = append(cmds, cmd) + } + + switch msg := msg.(type) { //nolint:gocritic + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.groupList.Resize(m.width, m.height) + case []list.Item: + m.completed = true + case duplicateitem.EditGroup: + m.groupModel = duplicateitem.NewGroupModel(m.immich, msg.Index, msg.Group, m.width, m.height) + m.currentView = itemView + case duplicateitem.BackFromGroup: + m.currentView = groupView + case tea.QuitMsg: + + } + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + switch m.currentView { + default: + return m.groupList.View() + case itemView: + return m.groupModel.View() + } +} + +// func (m Model) populateList(items duplicateitem.DuplicateListLoaded) Model { +// m.items = &items +// m.list = duplicatelist.NewListModel(m.items, m.width, m.height) +// return m +// } diff --git a/ui/duplicatepage/duplicateitem/event.go b/ui/duplicatepage/duplicateitem/event.go new file mode 100644 index 00000000..ba65b071 --- /dev/null +++ b/ui/duplicatepage/duplicateitem/event.go @@ -0,0 +1,10 @@ +package duplicateitem + +type EditGroup struct { + Group + Index int // index in the full list +} + +type BackFromGroup struct{} + +type DisplayThumbnail string diff --git a/ui/duplicatepage/duplicateitem/group.go b/ui/duplicatepage/duplicateitem/group.go new file mode 100644 index 00000000..9e3b1692 --- /dev/null +++ b/ui/duplicatepage/duplicateitem/group.go @@ -0,0 +1,30 @@ +package duplicateitem + +import ( + "fmt" + "time" + + "github.com/simulot/immich-go/immich" +) + +const TimeFormat = "2006/01/02 15:04:05 Z07:00" + +// Group groups duplicated assets +type Group struct { + Date time.Time + Name string + Assets []*immich.Asset +} + +// Implements the list.Item interface +func (i Group) FilterValue() string { + return i.Name + i.Date.Format(TimeFormat) +} + +func (i Group) Description() string { + return fmt.Sprintf("%d files", len(i.Assets)) +} + +func (i Group) Title() string { + return fmt.Sprintf("%s %s", i.Date.Format(TimeFormat), i.Name) +} diff --git a/ui/duplicatepage/duplicateitem/item.go b/ui/duplicatepage/duplicateitem/item.go new file mode 100644 index 00000000..2fcb43ac --- /dev/null +++ b/ui/duplicatepage/duplicateitem/item.go @@ -0,0 +1,21 @@ +package duplicateitem + +import ( + "github.com/simulot/immich-go/immich" +) + +type Item struct { + asset *immich.Asset +} + +func (i Item) FilterValue() string { + return i.asset.OriginalFileName +} + +func (i Item) Title() string { + return i.asset.OriginalFileName +} + +func (i Item) Description() string { + return "" +} diff --git a/ui/duplicatepage/duplicateitem/model.go b/ui/duplicatepage/duplicateitem/model.go new file mode 100644 index 00000000..c5612bd1 --- /dev/null +++ b/ui/duplicatepage/duplicateitem/model.go @@ -0,0 +1,141 @@ +package duplicateitem + +import ( + "context" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/simulot/immich-go/helpers/asciimage" + "github.com/simulot/immich-go/immich" +) + +type currentFocus int + +const ( + focusOnLeft currentFocus = iota + focusOnRight +) + +type Model struct { + left list.Model + right *huh.Form + Index int // Group index in the main list + Group // Group being edited + selected int // Selected asset in the group + focus currentFocus + height, width int + fields *fields + immich immich.ImmichInterface + image string +} + +type fields struct { + name string + date string +} + +func NewGroupModel(immich immich.ImmichInterface, index int, g Group, width, height int) Model { + m := Model{ + Index: index, + Group: g, + selected: -1, + height: 30, + width: 80, + immich: immich, + } + + l := []list.Item{} + for _, a := range g.Assets { + l = append(l, Item{asset: a}) + } + m.left = list.New(l, list.NewDefaultDelegate(), width, height) + return m +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + m.left, cmd = m.left.Update(msg) + cmds = append(cmds, cmd) + + if newSelected := m.left.Index(); newSelected != m.selected { + cmd = m.selectItem(newSelected) + cmds = append(cmds, cmd) + + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyMsg: + switch msg.String() { + case "shift+tab": + if m.focus == focusOnLeft { + cmd = sendMsg(BackFromGroup{}) + cmds = append(cmds, cmd) + } else { + m.focus = focusOnLeft + } + case "tab": + if m.focus == focusOnLeft { + m.focus = focusOnRight + } + } + case DisplayThumbnail: + m.image = string(msg) + + } + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + l := m.left.View() + r := "" + if m.right != nil { + r = m.right.View() + "\n" + } + r += m.image + + return lipgloss.JoinHorizontal(lipgloss.Top, l, r) +} + +func (m *Model) selectItem(selected int) tea.Cmd { + m.selected = selected + m.image = "" + a := m.Group.Assets[m.selected] + m.fields = &fields{ + name: a.OriginalFileName, + date: a.ExifInfo.DateTimeOriginal.Format(TimeFormat), + } + m.right = huh.NewForm(huh.NewGroup( + huh.NewInput().Key("name").Title("File name").Placeholder("change the name").Value(&m.fields.name), + huh.NewInput().Key("date").Title("Date of capture").Placeholder("YYYY/MM/DD HH:MM:SS +00:00").Value(&m.fields.date), + huh.NewConfirm().Key("done").Title("Save").Affirmative("save").Negative("cancel"), + )) + cmds := []tea.Cmd{m.right.Init(), m.getThumbnail(selected)} + return tea.Batch(cmds...) +} + +func (m Model) getThumbnail(selected int) tea.Cmd { + img, err := m.immich.GetAssetThumbnail(context.Background(), m.Group.Assets[selected].ID) + if err == nil { + s, _ := asciimage.Utf8Renderer(img, 80, 50) + return sendMsg(DisplayThumbnail(s)) + } + return nil +} + +// func (m Model) getThumbnail() +func sendMsg[T any](m T) tea.Cmd { + return func() tea.Msg { + return m + } +} diff --git a/ui/duplicatepage/duplicatelist/duplicatelist.go b/ui/duplicatepage/duplicatelist/duplicatelist.go new file mode 100644 index 00000000..ac9b4bd9 --- /dev/null +++ b/ui/duplicatepage/duplicatelist/duplicatelist.go @@ -0,0 +1,68 @@ +package duplicatelist + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/simulot/immich-go/ui/duplicatepage/duplicateitem" +) + +type Model struct { + list list.Model +} + +func NewListModel(items []list.Item, width, height int) Model { + m := Model{} + m.list = list.New(items, list.NewDefaultDelegate(), width, height) + + return m +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + m.list, cmd = m.list.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + switch msg := msg.(type) { + case []list.Item: + m.list = list.New(msg, list.NewDefaultDelegate(), m.list.Width(), m.list.Height()) + m.list.Title = "Duplicates handling" + case DuplicateLoadingMsg: + m.list.Title = fmt.Sprintf("Loading assets %d(%d%%), %d duplicates detected.", msg.Checked, 100*msg.Checked/msg.Total, msg.Duplicated) + + case tea.KeyMsg: + switch msg.String() { //nolint:gocritic + case "enter": + cmds = append(cmds, sendMsg(duplicateitem.EditGroup{ + Index: m.list.Index(), + Group: m.list.Items()[m.list.Index()].(duplicateitem.Group), + })) + } + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + return m.list.View() +} + +func (m *Model) Resize(width, height int) { + m.list.SetWidth(width) + m.list.SetHeight(height) +} + +func sendMsg[T any](m T) tea.Cmd { + return func() tea.Msg { + return m + } +} diff --git a/ui/duplicatepage/duplicatelist/event.go b/ui/duplicatepage/duplicatelist/event.go new file mode 100644 index 00000000..1cdc2992 --- /dev/null +++ b/ui/duplicatepage/duplicatelist/event.go @@ -0,0 +1,8 @@ +package duplicatelist + +// DuplicateLoadingMsg informs the model about the progression of the asset checking +type DuplicateLoadingMsg struct { + Total int + Checked int + Duplicated int +} diff --git a/ui/duplicatepage/events.go b/ui/duplicatepage/events.go new file mode 100644 index 00000000..5fbeff6a --- /dev/null +++ b/ui/duplicatepage/events.go @@ -0,0 +1,6 @@ +package duplicatepage + +// Signal an error +type DuplicateListError struct { + Err error +}