package tui import ( "context" "fmt" "io" "os" "strings" "charm.land/bubbletea/v2" "github.com/charmbracelet/colorprofile" "intake/internal/artwork" "intake/internal/cli" "intake/internal/config" "intake/internal/plan" "intake/internal/scan" ) type Runner struct { Cfg config.Config choices map[string]CoverOption collisions map[string]string } func NewRunner(cfg config.Config) *Runner { return &Runner{ Cfg: cfg, choices: map[string]CoverOption{}, collisions: map[string]string{}, } } func (r *Runner) Prepare(ctx context.Context, dir string) (plan.Plan, error) { app := r.app(nil) return app.PrepareAlbum(ctx, dir) } func (r *Runner) CoverOptions(ctx context.Context, p plan.Plan) ([]CoverOption, error) { app := r.app(nil) cands, warnings, err := app.CoverCandidates(ctx, p) if err != nil { return nil, err } for _, w := range warnings { _ = w } out := make([]CoverOption, 0, len(cands)) for _, c := range cands { out = append(out, CoverOption{ Path: c.Path, Source: string(c.Source), Label: c.Label, Width: c.Width, Height: c.Height, Bytes: c.Bytes, Placeholder: c.Source == artwork.SourcePlaceholder, }) } return out, nil } func (r *Runner) ApplyCover(p *plan.Plan, choice CoverOption) error { r.choices[p.Album.Dir] = choice return nil } func (r *Runner) Journal(ctx context.Context) ([]JournalEntry, error) { app := r.app(nil) entries, err := app.JournalEntries(ctx) if err != nil { return nil, err } out := make([]JournalEntry, 0, len(entries)) for _, e := range entries { out = append(out, JournalEntry{ Source: e.Source, AlbumArtist: e.AlbumArtist, Album: e.Album, Phase: e.Phase, ArchiveDir: e.ArchiveDir, LibraryDir: e.LibraryDir, Tracks: e.Tracks, Placeholder: e.Placeholder, Library: e.Library, Missing: e.Missing, Unrecorded: e.Unrecorded, }) } return out, nil } func (r *Runner) Existing(ctx context.Context, p plan.Plan) (Existing, bool) { e, ok := r.app(nil).LookupExisting(p) if !ok { return Existing{}, false } return Existing{ ArchiveDir: e.ArchiveDir, LibraryDir: e.LibraryDir, AlbumArtist: e.AlbumArtist, Album: e.Album, Tracks: e.Tracks, SamePath: e.SamePath, }, true } // Collide records what to do when the destination already exists, for the album // the user is looking at right now. func (r *Runner) Collide(albumDir, mode string) { r.collisions[albumDir] = mode } // Attention maps each staging directory an unattended run parked to the reason. func (r *Runner) Attention(ctx context.Context) (map[string]string, error) { app := r.app(nil) entries, err := app.JournalEntries(ctx) if err != nil { return nil, err } out := map[string]string{} for _, e := range entries { if e.NeedsAttention { reason := e.Reason if reason == "" { reason = "needs a decision" } out[e.Source] = reason } } return out, nil } func (r *Runner) Batch(ctx context.Context, progress func(string)) (BatchReport, error) { logf := func(format string, args ...any) { if progress != nil { progress(strings.TrimSpace(fmt.Sprintf(format, args...))) } } rep, err := r.app(logf).Batch(ctx) return BatchReport{Filed: rep.Filed, Parked: rep.Parked, Failed: rep.Failed}, err } func (r *Runner) ForgetSource(source string) error { return r.app(nil).ForgetSource(source) } func (r *Runner) UnfileSource(source string, progress func(string)) error { logf := func(format string, args ...any) { if progress != nil { progress(strings.TrimSpace(fmt.Sprintf(format, args...))) } } return r.app(logf).UnfileSource(source, progress) } func (r *Runner) Run(ctx context.Context, album scan.Album, progress func(string)) (Outcome, error) { logf := func(format string, args ...any) { if progress != nil { progress(strings.TrimSpace(fmt.Sprintf(format, args...))) } } app := r.app(logf) choice, ok := r.choices[album.Dir] if !ok && !r.Cfg.NoCover { res, err := app.FileAlbum(ctx, album.Dir, nil) if err != nil { return Outcome{}, err } delete(r.choices, album.Dir) return outcomeOf(res), nil } if mode := r.collisions[album.Dir]; mode != "" { app.Collision = mode } res, err := app.FileAlbumWithCover(ctx, album.Dir, choice.Path, choice.Placeholder, nil) delete(r.choices, album.Dir) delete(r.collisions, album.Dir) if err != nil { return Outcome{}, err } return outcomeOf(res), nil } func outcomeOf(res cli.FileResult) Outcome { return Outcome{ ArchiveDir: res.ArchiveDir, LibraryDir: res.LibraryDir, Files: res.Files, Bytes: res.Bytes, Cover: res.Cover, Placeholder: res.Placeholder, Library: res.Library, Warnings: res.Warnings, } } func (r *Runner) app(logf func(string, ...any)) *cli.App { a := cli.New(r.Cfg, os.Stdout, os.Stderr, os.Stdin) a.DryRun = r.Cfg.DryRun a.Yes = r.Cfg.Yes a.ImagePreview = false if logf != nil { a.Logf = func(line string) { logf("%s", line) } } else { a.Quiet = true } return a } func Run(cfg config.Config, out io.Writer, in io.Reader) error { r := NewRunner(cfg) m := New(Options{ Config: cfg, StagingDir: cfg.Staging(), Prepare: r.Prepare, Run: r.Run, CoverOptions: r.CoverOptions, ApplyCover: r.ApplyCover, Attention: r.Attention, Existing: r.Existing, Collide: r.Collide, Batch: r.Batch, Journal: r.Journal, ForgetSource: r.ForgetSource, UnfileSource: r.UnfileSource, }) opts := []tea.ProgramOption{tea.WithInput(in)} if out != nil { opts = append(opts, tea.WithOutput(out)) } opts = append(opts, programColourOptions()...) // Staged cover images outlive the frame that placed them, so they are // cleaned up here rather than by whatever drew them. Deferred because the // program exits on ctrl+c and on error just as much as on quit. defer m.prev.Close() if _, err := tea.NewProgram(m, opts...).Run(); err != nil { return err } return nil } func programColourOptions() []tea.ProgramOption { profile, ok := requiredColourProfile() if !ok { return nil } return []tea.ProgramOption{tea.WithColorProfile(profile)} } func requiredColourProfile() (colorprofile.Profile, bool) { // bubbletea quantises colours to the terminal's profile and the image id rides // in one. at 256 colours id 7800 goes out as palette index 18, which matches // no image, so the cover silently doesn't draw. if detectImageMode() != imageKitty { return 0, false } return colorprofile.TrueColor, true }