git/intake/main
internal/scan/scan.go
206 lines5 KB last change raw · history
package scan

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"

	"intake/internal/audio"
)

type Status string

const (
	StatusReady      Status = "ready"
	StatusEmpty      Status = "empty"
	StatusUnreadable Status = "unreadable"
)

type Album struct {
	Dir       string
	Name      string
	Status    Status
	Notes     []string
	Tracks    []audio.Track
	Subdirs   int
	TotalDur  float64
	FileCount int
}

func One(ctx context.Context, dir string) (Album, error) {
	return album(ctx, dir, Options{Probe: true})
}

type Options struct {
	Probe bool
	// Ignore names the staging folders to leave out entirely. A folder that has
	// turned out to be junk, or a release somebody is not ready to deal with,
	// should stop coming back rather than be skipped by hand every run.
	Ignore map[string]bool
}

func Staging(ctx context.Context, dir string) ([]Album, error) {
	return StagingWith(ctx, dir, Options{Probe: true})
}

func StagingWith(ctx context.Context, dir string, opts Options) ([]Album, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	var albums []Album
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		if strings.HasPrefix(e.Name(), ".") {
			continue
		}
		if opts.Ignore[e.Name()] {
			continue
		}
		a, err := album(ctx, filepath.Join(dir, e.Name()), opts)
		if err != nil {
			return nil, err
		}
		albums = append(albums, a)
	}
	sort.Slice(albums, func(i, j int) bool { return albums[i].Name < albums[j].Name })
	return albums, nil
}

func album(ctx context.Context, dir string, opts Options) (Album, error) {
	a := Album{Dir: dir, Name: filepath.Base(dir)}

	files, discHints, err := collectAudio(dir, &a)
	if err != nil {
		return a, err
	}
	if len(files) == 0 {
		a.Status = StatusEmpty
		a.Notes = append(a.Notes, "no audio files found")
		return a, nil
	}

	if !opts.Probe {
		a.Status = StatusReady
		a.FileCount = len(files)
		return a, nil
	}

	a.Status = StatusReady
	a.FileCount = len(files)
	skipped := 0
	for _, p := range files {
		t, err := audio.Probe(ctx, p)
		if err != nil {
			skipped++
			a.Notes = append(a.Notes, fmt.Sprintf("ignoring unreadable file %s", filepath.Base(p)))
			continue
		}
		t.DiscHint = discHints[p]
		a.Tracks = append(a.Tracks, t)
		a.TotalDur += t.Duration
	}
	if len(a.Tracks) == 0 {
		a.Status = StatusUnreadable
		a.Notes = append(a.Notes, "no readable audio files")
	} else if skipped > 0 {
		a.Notes = append(a.Notes, fmt.Sprintf("%d unreadable file(s) ignored", skipped))
	}
	sort.SliceStable(a.Tracks, func(i, j int) bool {
		return filepath.Base(a.Tracks[i].Path) < filepath.Base(a.Tracks[j].Path)
	})
	return a, nil
}

// discFromFolder reads a disc number out of a folder named CD1, Disc 2, Disk_3
// and the like. Multi-disc rips are very often arranged this way with nothing in
// the tags, which otherwise leaves every disc numbered 1 and every track number
// repeated across them.
func discFromFolder(name string) int {
	m := discFolder.FindStringSubmatch(name)
	if m == nil {
		return 0
	}
	n, err := strconv.Atoi(m[1])
	if err != nil || n < 1 {
		return 0
	}
	return n
}

var discFolder = regexp.MustCompile(`(?i)^\s*(?:cd|disc|disk)\s*[-_. ]?\s*(\d{1,2})\s*$`)

func collectAudio(dir string, a *Album) ([]string, map[string]int, error) {
	var files []string
	discHints := map[string]int{}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, nil, err
	}
	for _, e := range entries {
		p := filepath.Join(dir, e.Name())
		switch {
		case e.IsDir():
			if strings.HasPrefix(e.Name(), ".") {
				continue
			}
			a.Subdirs++
			sub, err := os.ReadDir(p)
			if err != nil {
				return nil, nil, err
			}
			hint := discFromFolder(e.Name())
			for _, s := range sub {
				if s.IsDir() || strings.HasPrefix(s.Name(), ".") {
					continue
				}
				if audio.IsAudio(s.Name()) {
					path := filepath.Join(p, s.Name())
					files = append(files, path)
					if hint > 0 {
						discHints[path] = hint
					}
				}
			}
		case strings.HasPrefix(e.Name(), "."), strings.HasPrefix(e.Name(), "._"):
			continue
		default:
			if audio.IsAudio(e.Name()) {
				files = append(files, p)
			}
		}
	}
	return files, discHints, nil
}

var leadingNumber = regexp.MustCompile(`^\s*(?:(\d{1,2})[.\-_ ]+)?(?:(\d{1,3})\s*[.\-_ ]+)?(.*)$`)

func FromFilename(name string) (disc, track int, title string) {
	base := strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))
	base = strings.ReplaceAll(base, "_", " ")

	m := leadingNumber.FindStringSubmatch(base)
	if m == nil {
		return 0, 0, strings.TrimSpace(base)
	}
	switch {
	case m[1] != "" && m[2] != "":
		fmt.Sscanf(m[1], "%d", &disc)
		fmt.Sscanf(m[2], "%d", &track)
		return disc, track, strings.TrimSpace(m[3])
	case m[1] != "":
		var n int
		fmt.Sscanf(m[1], "%d", &n)
		return 0, n, strings.TrimSpace(m[3])
	default:
		return 0, 0, strings.TrimSpace(m[3])
	}
}