Files
zampler/internal/scan/scan.go
T
claudeandClaude Sonnet 5 800404203a
PR Checks / checks (pull_request) Successful in 1m43s
Build / build-and-push (push) Successful in 1m8s
replace archived code.aneur.in/go/* dependencies
Everything under code.aneur.in/go/* except validate was archived. Swap
each for its replacement; validate stays.

- rest -> internal/rest: the JSON response/error helpers zampler actually
  uses (~110 lines), same {"message","data"} wire shape and status codes.
- sqan -> internal/scan: a filepath.WalkDir walk. zampler only filtered
  by extension; sqan's path semantics (absolute root, rooted RelativePath,
  "/" RelativeDir for root-level files) are kept.
- sqimple + sqaffold query building -> github.com/doug-martin/goqu/v9,
  which also does the row scanning that record + sqaffold provided. A
  fileRow struct with db tags maps to types.File (millis <-> time.Time).
- sqaffold.Migrate + version.Migration -> a ~40-line migrator in
  internal/database/migration.go tracking applied names in a
  schema_migrations table. goose was disproportionate for one migration.
- version -> gone (only its Migration helper was used).

Net dependency change: -6 code.aneur.in/go modules, +1 (goqu).

go build / go vet / go test ./... green; manual run verified migration,
scan+insert, list/get/search/sort/paging, 404 and 400 shapes, and
rescan idempotency (on conflict replace).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:00:45 +01:00

122 lines
2.8 KiB
Go

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