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])
}
}