package scanner import ( "fmt" "log/slog" "os" "path/filepath" "strings" "time" "github.com/oklog/ulid/v2" ) var allowedExts = map[string]bool{ ".mp3": true, ".wav": true, ".m4a": true, ".ogg": true, ".flac": true, ".webm": true, } type ClaimedFile struct { TaskID string FilePath string Filename string Size int64 } type Config struct { StorageRoot string IncomingDir string ProcessingDir string FailedDir string StableWindow time.Duration StableChecks int } type Scanner struct { cfg Config } func New(cfg Config) *Scanner { return &Scanner{cfg: cfg} } func (s *Scanner) EnsureDirs() error { for _, dir := range []string{s.cfg.IncomingDir, s.cfg.ProcessingDir, s.cfg.FailedDir} { if err := os.MkdirAll(filepath.Join(s.cfg.StorageRoot, dir), 0o755); err != nil { return err } } return nil } func (s *Scanner) ScanOnce() ([]ClaimedFile, error) { incoming := filepath.Join(s.cfg.StorageRoot, s.cfg.IncomingDir) entries, err := os.ReadDir(incoming) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var claimed []ClaimedFile for _, e := range entries { if e.IsDir() { continue } name := e.Name() if strings.HasPrefix(name, ".") || strings.HasSuffix(strings.ToLower(name), ".tmp") { continue } ext := strings.ToLower(filepath.Ext(name)) if !allowedExts[ext] { continue } src := filepath.Join(incoming, name) if !s.isStable(src) { continue } cf, err := s.claim(src, name, ext) if err != nil { slog.Warn("claim failed", "file", name, "error", err) continue } claimed = append(claimed, cf) } return claimed, nil } func (s *Scanner) isStable(path string) bool { var lastSize int64 = -1 for i := 0; i < s.cfg.StableChecks; i++ { info, err := os.Stat(path) if err != nil { return false } size := info.Size() if lastSize >= 0 && size != lastSize { return false } lastSize = size if i < s.cfg.StableChecks-1 { time.Sleep(s.cfg.StableWindow) } } return true } func (s *Scanner) claim(src, originalName, ext string) (ClaimedFile, error) { info, err := os.Stat(src) if err != nil { return ClaimedFile{}, err } taskID := ulid.Make().String() processing := filepath.Join(s.cfg.StorageRoot, s.cfg.ProcessingDir) dst := filepath.Join(processing, taskID+ext) if err := os.Rename(src, dst); err != nil { return ClaimedFile{}, fmt.Errorf("rename: %w", err) } slog.Info("claimed file", "task_id", taskID, "filename", originalName, "path", dst, "size", info.Size()) return ClaimedFile{ TaskID: taskID, FilePath: dst, Filename: originalName, Size: info.Size(), }, nil } func (s *Scanner) RollbackToIncoming(filePath, originalName string) error { incoming := filepath.Join(s.cfg.StorageRoot, s.cfg.IncomingDir) dst := filepath.Join(incoming, originalName) if err := os.Rename(filePath, dst); err != nil { return s.MoveToFailed(filePath, originalName) } return nil } func (s *Scanner) MoveToFailed(filePath, originalName string) error { failed := filepath.Join(s.cfg.StorageRoot, s.cfg.FailedDir) if err := os.MkdirAll(failed, 0o755); err != nil { return err } dst := filepath.Join(failed, originalName) return os.Rename(filePath, dst) }