Files
zampler/internal/scan/scan.go
T

122 lines
2.8 KiB
Go
Raw Normal View History

// Package scan walks a directory tree and yields the audio files under it.
// It replaces the archived code.aneur.in/go/sqan with a plain
// filepath.WalkDir; zampler only ever filtered by extension.
package scan
import (
"io/fs"
"iter"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// File describes one scanned file. Paths mirror sqan: RootDir and
// AbsolutePath are absolute, RelativePath is rooted with a leading slash
// ("/album/track.mp3"), RelativeDir is its directory ("/album", or "/" for
// a file directly under the root).
type File struct {
RootDir string
AbsolutePath string
RelativeDir string
RelativePath string
Filename string
Extension string
Size int64
Modified time.Time
}
// Scanner walks a single root directory.
type Scanner struct {
root string
extensions []string
onError func(error)
}
// New creates a Scanner for root. A relative root is resolved against the
// working directory.
func New(root string) *Scanner {
if !filepath.IsAbs(root) {
if cwd, err := os.Getwd(); err == nil {
root = filepath.Join(cwd, root)
}
}
return &Scanner{root: root, onError: func(error) {}}
}
// WithExtensions restricts the scan to files with one of the given
// extensions (without the dot), compared case-insensitively.
func (s *Scanner) WithExtensions(exts ...string) *Scanner {
for _, ext := range exts {
s.extensions = append(s.extensions, strings.ToLower(ext))
}
return s
}
// OnError registers a callback for errors hit while walking. Unset, they
// are ignored and the walk continues.
func (s *Scanner) OnError(fn func(error)) *Scanner {
s.onError = fn
return s
}
// Scan walks the tree, yielding one *File per matching file. Stopping the
// range stops the walk.
func (s *Scanner) Scan() iter.Seq[*File] {
return func(yield func(*File) bool) {
filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
s.onError(err)
return nil
}
if d.IsDir() {
return nil
}
name := d.Name()
ext := ""
if i := strings.LastIndexByte(name, '.'); i > -1 {
ext = strings.ToLower(name[i+1:])
}
if len(s.extensions) > 0 && !slices.Contains(s.extensions, ext) {
return nil
}
rel, relErr := filepath.Rel(s.root, path)
if relErr != nil {
s.onError(relErr)
return nil
}
rel = "/" + filepath.ToSlash(rel)
relDir := "/"
if i := strings.LastIndexByte(rel, '/'); i > 0 {
relDir = rel[:i]
}
info, infoErr := d.Info()
if infoErr != nil {
s.onError(infoErr)
return nil
}
file := &File{
RootDir: s.root,
AbsolutePath: path,
RelativeDir: relDir,
RelativePath: rel,
Filename: name,
Extension: ext,
Size: info.Size(),
Modified: info.ModTime(),
}
if !yield(file) {
return filepath.SkipAll
}
return nil
})
}
}