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
}