74 lines
2.7 KiB
Go
74 lines
2.7 KiB
Go
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...)
|
||
|
|
}
|