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