Files

74 lines
2.7 KiB
Go
Raw Permalink Normal View History

package scanner
import (
"errors"
"fmt"
"sync"
)
// errFolderHasNoMedia signals that a candidate folder contained zero media
// files of the kind its parser looks for. Folder-scoped parsers
// (parseAudiobookFolder, parsePodcastShow) return it so their reconcile
// callers can skip the folder quietly.
//
// It deliberately does NOT wrap os.ErrNotExist. The folder parsers shell out
// to ffprobe, and a missing or misconfigured ffprobe binary surfaces as an
// exec error that wraps fs.ErrNotExist ("fork/exec /path/ffprobe: no such
// file or directory"). Skipping on os.ErrNotExist therefore swallowed a
// server misconfiguration as "this folder has no audio", leaving scans that
// reported processed=N failed=0 while indexing nothing at all.
var errFolderHasNoMedia = errors.New("folder contains no media files")
// maxRetainedScanFailures caps how many per-item errors a scan keeps for its
// "everything failed" summary. A misconfigured ffprobe fails every candidate,
// so on a library with hundreds of thousands of folders an uncapped slice
// accumulates hundreds of megabytes of near-identical strings for the whole
// scan, and joining them produces a single error that is written verbatim into
// scan_runs.error_message and republished over the events channel and the
// admin SSE stream. The first few failures identify the cause; the rest only
// repeat it.
const maxRetainedScanFailures = 20
// scanFailures collects per-item scan errors for the all-failed summary while
// retaining at most maxRetainedScanFailures of them. It is safe for concurrent
// use by the scan worker pools.
type scanFailures struct {
mu sync.Mutex
retained []error
total int
}
// addf records a formatted failure. The error is only formatted while under
// the cap, so the common runaway case — every candidate failing for the same
// reason — does not pay for building strings it will discard.
func (f *scanFailures) addf(format string, args ...any) {
f.mu.Lock()
defer f.mu.Unlock()
f.total++
if len(f.retained) < maxRetainedScanFailures {
f.retained = append(f.retained, fmt.Errorf(format, args...))
}
}
// len reports how many failures were recorded, including elided ones.
func (f *scanFailures) len() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.total
}
// join returns the retained failures as one error, with a trailing note when
// failures were elided. Returns nil when nothing was recorded.
func (f *scanFailures) join() error {
f.mu.Lock()
defer f.mu.Unlock()
if f.total == 0 {
return nil
}
if elided := f.total - len(f.retained); elided > 0 {
return errors.Join(append(append([]error(nil), f.retained...),
fmt.Errorf("and %d more failures (elided)", elided))...)
}
return errors.Join(f.retained...)
}