git/intake/main
internal/cli/cover.go
194 lines6 KB last change raw · history
package cli

import (
	"bufio"
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"

	"intake/internal/artwork"
	"intake/internal/audio"
)

type coverChoice struct {
	Path        string
	Placeholder bool
	Source      artwork.Source
	Label       string
}

func (a *App) chooseCover(ctx context.Context, info artwork.AlbumInfo, tracks []audio.Track, stagingDir string, excludeHashes []string, auto bool) (coverChoice, error) {
	if auto {
		local, _, _ := artwork.Discover(ctx, info, tracks, artwork.Options{
			StagingDir:       stagingDir,
			LocalCoverFolder: a.Cfg.CoverFolder,
			Offline:          true,
			CacheDir:         filepath.Join(a.Cfg.StateDir, "art-cache"),
			ExcludeHashes:    excludeHashes,
		})
		for _, c := range local {
			if c.Preferred || c.Source == artwork.SourceFolder || c.Source == artwork.SourceLocalFolder {
				a.logf("cover: %s (%s)", c.Label, c.Describe())
				return coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, nil
			}
		}
		remote, _, _ := artwork.Discover(ctx, info, tracks, artwork.Options{
			StagingDir:       stagingDir,
			LocalCoverFolder: a.Cfg.CoverFolder,
			Offline:          a.Cfg.Offline,
			CacheDir:         filepath.Join(a.Cfg.StateDir, "art-cache"),
			ExcludeHashes:    excludeHashes,
			Progress:         func(msg string) { a.logf("  %s", msg) },
			Sources:          []artwork.Source{artwork.SourceCoverArt, artwork.SourceDeezer, artwork.SourceITunes},
		})
		for _, c := range remote {
			if c.ExactMatch() {
				a.logf("cover: %s (%s)", c.Label, c.Describe())
				return coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, nil
			}
		}
		a.logf("cover: no confident match; will file with a placeholder")
		return a.placeholder(ctx, info)
	}

	candidates, warnings, err := artwork.Discover(ctx, info, tracks, artwork.Options{
		StagingDir:       stagingDir,
		LocalCoverFolder: a.Cfg.CoverFolder,
		Offline:          a.Cfg.Offline,
		CacheDir:         filepath.Join(a.Cfg.StateDir, "art-cache"),
		ExcludeHashes:    excludeHashes,
		Progress:         func(msg string) { a.logf("  %s", msg) },
	})
	if err != nil {
		return coverChoice{}, err
	}
	for _, w := range warnings {
		a.logf("  warning: %s", w)
	}

	if len(candidates) > 1 && candidates[0].Preferred && candidates[0].IsLowResolution() {
		a.logf("  note: the embedded art is only %s; larger options are listed below", candidates[0].Describe())
	}

	placeholderPath, err := a.makePlaceholder(ctx, info)
	if err != nil {
		a.logf("  warning: could not generate a placeholder: %v", err)
	}

	a.logf("")
	a.logf("COVER ART")
	if len(candidates) == 0 {
		a.logf("  no candidates found from any source.")
	}
	for i, c := range candidates {
		a.logf("  [%d] %-16s %s", i+1, c.Source, c.Describe())
		a.logf("      %s", c.Label)
		a.renderImage(c.Path)
	}
	if placeholderPath != "" {
		a.logf("  [0] placeholder      a generated stand-in, recorded so you can replace it later")
		a.logf("  [n] none             file the album with no cover at all")
	} else {
		a.logf("  [n] none             file the album with no cover at all")
	}
	a.logf("  [p] pick a file      use an image you already have")

	for attempt := 0; attempt < 5; attempt++ {
		fmt.Fprintf(a.Out, "choose a cover [1-%d, 0=placeholder, n=none, p=file]: ", len(candidates))
		_ = os.Stdout.Sync()
		line, _ := bufio.NewReader(a.In).ReadString('\n')
		answer := strings.TrimSpace(line)

		switch {
		case answer == "":
			if len(candidates) > 0 {
				c := candidates[0]
				return coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, nil
			}
		case strings.EqualFold(answer, "n"):
			a.logf("cover: none")
			return coverChoice{}, nil
		case strings.EqualFold(answer, "p"):
			fmt.Fprintf(a.Out, "path to image: ")
			_ = os.Stdout.Sync()
			pathLine, _ := bufio.NewReader(a.In).ReadString('\n')
			path := strings.TrimSpace(pathLine)
			if path == "" {
				continue
			}
			path = strings.Trim(path, `"'`)
			if _, err := os.Stat(path); err != nil {
				a.logf("  cannot read %s: %v", path, err)
				continue
			}
			return coverChoice{Path: path, Source: artwork.SourceLocalFolder, Label: filepath.Base(path)}, nil
		case answer == "0" && placeholderPath != "":
			a.logf("cover: placeholder (recorded; use 'intake library re-cover' once you have real art)")
			return coverChoice{Path: placeholderPath, Placeholder: true, Source: artwork.SourcePlaceholder, Label: "placeholder"}, nil
		default:
			if n, err := strconv.Atoi(answer); err == nil && n >= 1 && n <= len(candidates) {
				c := candidates[n-1]
				return coverChoice{Path: c.Path, Source: c.Source, Label: c.Label}, nil
			}
		}
		a.logf("  please answer with a number between 1 and %d, or 0, n, p", len(candidates))
	}
	return coverChoice{}, fmt.Errorf("no cover chosen after several attempts")
}

func (a *App) makePlaceholder(ctx context.Context, info artwork.AlbumInfo) (string, error) {
	dir := filepath.Join(a.Cfg.StateDir, "placeholders")
	path := filepath.Join(dir, "placeholder.jpg")
	if _, err := os.Stat(path); err == nil {
		return path, nil
	}
	if err := artwork.Placeholder(ctx, info, path, a.Cfg.LibraryArtPx); err != nil {
		return "", err
	}
	return path, nil
}

func (a *App) placeholder(ctx context.Context, info artwork.AlbumInfo) (coverChoice, error) {
	path, err := a.makePlaceholder(ctx, info)
	if err != nil {
		return coverChoice{}, nil
	}
	return coverChoice{Path: path, Placeholder: true, Source: artwork.SourcePlaceholder, Label: "placeholder"}, nil
}

func (a *App) renderImage(path string) {
	if path == "" || !a.ImagePreview {
		return
	}
	width := a.terminalColumns() - 6
	if width > 44 {
		width = 44
	}
	if width < 16 {
		return
	}
	cmd := exec.Command("chafa", "--format", "symbols", "--size", fmt.Sprintf("%dx24", width), path)
	cmd.Stdout = a.Out
	cmd.Stderr = nil
	_ = cmd.Run()
}

func (a *App) terminalColumns() int {
	if c, err := strconv.Atoi(os.Getenv("COLUMNS")); err == nil && c > 0 {
		return c
	}
	cmd := exec.Command("tput", "cols")
	var out strings.Builder
	cmd.Stdout = &out
	if err := cmd.Run(); err == nil {
		if c, err := strconv.Atoi(strings.TrimSpace(out.String())); err == nil && c > 0 {
			return c
		}
	}
	return 80
}