git/intake/main
internal/config/config.go
339 lines8 KB last change raw · history
package config

import (
	"bufio"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

type Config struct {
	Root         string
	StagingDir   string
	ArchiveDir   string
	LibraryDir   string
	StateDir     string
	Editor       string
	Codec        string
	Quality      int
	DiscPrefix   string
	LibraryArtPx int
	ArchiveArtPx int
	Contacts     string
	UserAgent    string
	CoverFolder  string
	Placeholder  string
	KeepStaging  bool
	Offline      bool
	NoCover      bool
	NoLibrary    bool
	VerifyDecode string
	DryRun       bool
	Yes          bool

	Path string
}

func Defaults() Config {
	home, _ := os.UserHomeDir()
	state := os.Getenv("XDG_STATE_HOME")
	if state == "" {
		state = filepath.Join(home, ".local", "state")
	}
	return Config{
		Root:         filepath.Join(home, "music"),
		StagingDir:   "staging",
		ArchiveDir:   "archive",
		LibraryDir:   "library",
		StateDir:     filepath.Join(state, "intake"),
		Codec:        "vorbis",
		Quality:      9,
		DiscPrefix:   "always",
		LibraryArtPx: 1000,
		ArchiveArtPx: 1600,
		UserAgent:    "intake/0.1 ( https://example.invalid )",
		Placeholder:  "PLACEHOLDER.jpg",
		VerifyDecode: "sample",
	}
}

func DefaultConfigPath() string {
	if p := os.Getenv("INTAKE_CONFIG"); p != "" {
		return p
	}
	base := os.Getenv("XDG_CONFIG_HOME")
	if base == "" {
		home, _ := os.UserHomeDir()
		base = filepath.Join(home, ".config")
	}
	return filepath.Join(base, "intake", "config.toml")
}

func Load(path string) (Config, error) {
	cfg := Defaults()
	if root := os.Getenv("INTAKE_ROOT"); root != "" {
		cfg.Root = root
	}
	if path == "" {
		path = DefaultConfigPath()
	}
	if err := applyFile(&cfg, path); err != nil {
		return cfg, err
	}
	cfg.Path = path
	return cfg, nil
}

func applyFile(cfg *Config, path string) error {
	f, err := os.Open(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		return err
	}
	defer f.Close()

	sc := bufio.NewScanner(f)
	line := 0
	for sc.Scan() {
		line++
		text := strings.TrimSpace(sc.Text())
		if text == "" || strings.HasPrefix(text, "#") {
			continue
		}
		key, value, ok := strings.Cut(text, "=")
		if !ok {
			return fmt.Errorf("%s:%d: expected key = value", path, line)
		}
		key = strings.TrimSpace(key)
		value = strings.TrimSpace(value)
		if i := strings.IndexByte(value, '#'); i >= 0 && !strings.HasPrefix(value, "\"") {
			value = strings.TrimSpace(value[:i])
		}
		value = strings.Trim(value, `"'`)
		if err := cfg.set(key, value); err != nil {
			return fmt.Errorf("%s:%d: %w", path, line, err)
		}
	}
	return sc.Err()
}

func (c *Config) set(key, value string) error {
	switch key {
	case "root":
		c.Root = value
	case "staging_dir":
		c.StagingDir = value
	case "archive_dir":
		c.ArchiveDir = value
	case "library_dir":
		c.LibraryDir = value
	case "state_dir":
		c.StateDir = value
	case "editor":
		c.Editor = value
	case "codec":
		c.Codec = value
	case "quality":
		n, err := strconv.Atoi(value)
		if err != nil {
			return fmt.Errorf("quality: %q is not a number", value)
		}
		if n < 1 || n > 10 {
			return fmt.Errorf("quality: %d is outside the range 1-10 that libvorbis accepts", n)
		}
		c.Quality = n
	case "disc_prefix":
		v := strings.ToLower(strings.TrimSpace(value))
		switch v {
		case "always", "never", "auto":
			c.DiscPrefix = v
		default:
			return fmt.Errorf("disc_prefix: %q is not one of always, never or auto", value)
		}
	case "library_art_px":
		n, err := strconv.Atoi(value)
		if err != nil {
			return fmt.Errorf("library_art_px: %q is not a number", value)
		}
		c.LibraryArtPx = n
	case "archive_art_px":
		n, err := strconv.Atoi(value)
		if err != nil {
			return fmt.Errorf("archive_art_px: %q is not a number", value)
		}
		c.ArchiveArtPx = n
	case "contacts":
		c.Contacts = value
	case "user_agent":
		c.UserAgent = value
	case "cover_folder":
		c.CoverFolder = value
	case "placeholder":
		c.Placeholder = value
	case "keep_staging":
		b, err := strconv.ParseBool(value)
		if err != nil {
			return fmt.Errorf("keep_staging: %q is not a boolean", value)
		}
		c.KeepStaging = b
	case "verify_decode":
		// The decode check after filing runs on the sampled files, on every
		// file, or not at all. An unrecognised value used to be stored as it was
		// and then matched none of those, so a typo quietly turned the only
		// end-to-end check of the written audio into a no-op.
		v := strings.ToLower(strings.TrimSpace(value))
		switch v {
		case "all", "sample", "none":
			c.VerifyDecode = v
		default:
			return fmt.Errorf("verify_decode: %q is not one of all, sample or none", value)
		}
	case "offline":
		b, err := strconv.ParseBool(value)
		if err != nil {
			return fmt.Errorf("offline: %q is not a boolean", value)
		}
		c.Offline = b
	case "no_cover":
		b, err := strconv.ParseBool(value)
		if err != nil {
			return fmt.Errorf("no_cover: %q is not a boolean", value)
		}
		c.NoCover = b
	case "no_library":
		b, err := strconv.ParseBool(value)
		if err != nil {
			return fmt.Errorf("no_library: %q is not a boolean", value)
		}
		c.NoLibrary = b
	default:
		return fmt.Errorf("unknown setting %q", key)
	}
	return nil
}

func expand(p string) string {
	if p == "~" || strings.HasPrefix(p, "~/") {
		if home, err := os.UserHomeDir(); err == nil {
			return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/"))
		}
	}
	return p
}

func (c Config) Staging() string { return filepath.Join(expand(c.Root), c.StagingDir) }

func (c Config) Archive() string { return filepath.Join(expand(c.Root), c.ArchiveDir) }

func (c Config) Library() string { return filepath.Join(expand(c.Root), c.LibraryDir) }

func (c Config) EditorCommand() string {
	if c.Editor != "" {
		return c.Editor
	}
	if v := os.Getenv("VISUAL"); v != "" {
		return v
	}
	if v := os.Getenv("EDITOR"); v != "" {
		return v
	}
	return "vi"
}

// IgnorePath is where the list of staging folders to leave alone lives. It sits
// beside staging, archive and library rather than in the state directory, because
// it describes this library rather than this machine's history of it.
func (c Config) IgnorePath() string { return filepath.Join(expand(c.Root), "ignore.txt") }

// LoadIgnore reads the names of staging folders to leave alone.
//
// Names, not paths: a folder called "Various Artists - Singles" that has turned
// out to be a mess should stay ignored whatever happens to staging in between.
func (c Config) LoadIgnore() (map[string]bool, error) {
	out := map[string]bool{}
	f, err := os.Open(c.IgnorePath())
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return out, nil
		}
		return out, err
	}
	defer f.Close()
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		out[line] = true
	}
	return out, sc.Err()
}

// AddIgnore adds a name, and RemoveIgnore drops one. Both rewrite the whole file
// sorted, so it stays readable and hand-editable, and both report whether
// anything changed.
func (c Config) AddIgnore(name string) (bool, error) {
	return c.editIgnore(func(set map[string]bool) bool {
		name = strings.TrimSpace(name)
		if name == "" || set[name] {
			return false
		}
		set[name] = true
		return true
	})
}

func (c Config) RemoveIgnore(name string) (bool, error) {
	return c.editIgnore(func(set map[string]bool) bool {
		name = strings.TrimSpace(name)
		if !set[name] {
			return false
		}
		delete(set, name)
		return true
	})
}

func (c Config) editIgnore(change func(map[string]bool) bool) (bool, error) {
	set, err := c.LoadIgnore()
	if err != nil {
		return false, err
	}
	if !change(set) {
		return false, nil
	}
	names := make([]string, 0, len(set))
	for n := range set {
		names = append(names, n)
	}
	sort.Strings(names)

	var b strings.Builder
	b.WriteString("# intake: staging folders to leave alone\n")
	b.WriteString("#\n")
	b.WriteString("# One folder name per line, exactly as it appears under staging/. These are\n")
	b.WriteString("# skipped by scan, by the album list and by a batch, so junk or a release you\n")
	b.WriteString("# are not ready to deal with stops coming back.\n\n")
	for _, n := range names {
		b.WriteString(n)
		b.WriteString("\n")
	}
	path := c.IgnorePath()
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
		return false, err
	}
	if err := os.Rename(tmp, path); err != nil {
		os.Remove(tmp)
		return false, err
	}
	return true, nil
}