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
}