git/intake/main
internal/tui/runner.go
263 lines6 KB last change raw · history
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
}