Files
baa33768d6 fix(scanner): stop reporting an unusable ffprobe as an empty folder (#468)
* fix(scanner): stop reporting an unusable ffprobe as an empty folder

parseAudiobookFolder and parsePodcastShow signalled "this folder holds no
audio files" by wrapping os.ErrNotExist, and their reconcile callers skipped
on that. exec also wraps fs.ErrNotExist when the configured ffprobe binary
cannot be run, so a wrong playback.ffmpeg_path made every candidate folder
look empty: the scan logged processed=N failed=0, indexed nothing, and gave
the operator no clue why the library stayed empty.

Introduce an errFolderHasNoMedia sentinel that deliberately does not wrap
os.ErrNotExist, and skip on that instead. A folder that disappears between
the scan walk and the parse still maps to the sentinel, so a mid-scan rename
or delete stays a quiet skip rather than a scan failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(scanner): bound the all-failed scan summary instead of joining every failure

The sentinel change in this PR makes a previously-unreachable path reachable.
Before it, a misconfigured ffprobe made every candidate folder look empty, so
the scan skipped everything and `failed` stayed 0 — the `failedCount ==
processedCount` branch never fired. Now that an unusable ffprobe propagates as
a real failure, that branch is the expected outcome of a first scan with a bad
`playback.ffmpeg_path`, and it joins one wrapped error per failed folder.

On the 240k-folder library the scan code is written for, `errors.Join` over
that slice produces a ~64 MB error string (measured) that is written verbatim
into `scan_runs.error_message` and republished over the Redis events channel
and the admin SSE stream. The `failures` slice itself also grew unbounded for
the whole scan even when the all-failed guard could not fire (any rescan with
`skipped > 0`), holding hundreds of megabytes across a multi-hour scan before
discarding it.

Add a `scanFailures` collector that retains the first 20 failures and counts
the rest, joining them with a trailing "and N more failures (elided)". The
retained sample still names the cause, which is the entire purpose of the
summary. The same 64 MB case now produces 5.4 KB.

The ebook and manga scans have the identical shape and the same exposure via
their own probe failures, so all four call sites share the collector rather
than fixing the two audio paths alone. `failMu` now guards only `cancelErr`
and is renamed `cancelMu` to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-26 23:35:36 -04:00

99 lines
3.1 KiB
Go

package scanner
import (
"errors"
"fmt"
"strings"
"sync"
"testing"
)
func TestScanFailuresJoinsEverythingUnderTheCap(t *testing.T) {
var failures scanFailures
sentinel := errors.New("boom")
failures.addf("/books/a: %w", sentinel)
failures.addf("/books/b: %w", sentinel)
if got := failures.len(); got != 2 {
t.Fatalf("len() = %d, want 2", got)
}
joined := failures.join()
if !errors.Is(joined, sentinel) {
t.Fatalf("joined error = %v, want it to wrap the sentinel", joined)
}
for _, want := range []string{"/books/a", "/books/b"} {
if !strings.Contains(joined.Error(), want) {
t.Fatalf("joined error %q missing %q", joined, want)
}
}
if strings.Contains(joined.Error(), "elided") {
t.Fatalf("joined error %q reported elisions with nothing elided", joined)
}
}
// A misconfigured ffprobe fails every candidate in the library, so the
// all-failed summary must stay a bounded diagnostic instead of growing with
// the folder count: it is written verbatim into scan_runs.error_message and
// republished to every admin SSE subscriber.
func TestScanFailuresCapsRetainedErrorsAndReportsTheRemainder(t *testing.T) {
var failures scanFailures
const total = maxRetainedScanFailures + 500
for i := 0; i < total; i++ {
failures.addf("/books/%d: %w", i, errors.New("fork/exec /usr/lib/jellyfin-ffmpeg/ffprobe: no such file or directory"))
}
if got := failures.len(); got != total {
t.Fatalf("len() = %d, want %d — the count must include elided failures", got, total)
}
if got := len(failures.retained); got != maxRetainedScanFailures {
t.Fatalf("retained %d errors, want the cap of %d", got, maxRetainedScanFailures)
}
joined := failures.join()
if !strings.Contains(joined.Error(), fmt.Sprintf("and %d more failures (elided)", total-maxRetainedScanFailures)) {
t.Fatalf("joined error does not report the elided remainder: %q", joined)
}
// The retained sample still names the cause, which is the whole point of
// the summary.
if !strings.Contains(joined.Error(), "/books/0") {
t.Fatalf("joined error dropped the first failure: %q", joined)
}
if strings.Contains(joined.Error(), fmt.Sprintf("/books/%d:", total-1)) {
t.Fatalf("joined error retained a failure past the cap: %q", joined)
}
}
func TestScanFailuresJoinIsNilWhenNothingFailed(t *testing.T) {
var failures scanFailures
if got := failures.len(); got != 0 {
t.Fatalf("len() = %d, want 0", got)
}
if err := failures.join(); err != nil {
t.Fatalf("join() = %v, want nil", err)
}
}
// The audiobook, ebook, and manga scans record failures from a worker pool.
func TestScanFailuresIsConcurrencySafe(t *testing.T) {
var failures scanFailures
var wg sync.WaitGroup
const workers, perWorker = 8, 200
for i := 0; i < workers; i++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
for j := 0; j < perWorker; j++ {
failures.addf("/books/%d-%d: %w", worker, j, errors.New("probe failed"))
}
}(i)
}
wg.Wait()
if got := failures.len(); got != workers*perWorker {
t.Fatalf("len() = %d, want %d", got, workers*perWorker)
}
if got := len(failures.retained); got != maxRetainedScanFailures {
t.Fatalf("retained %d errors, want the cap of %d", got, maxRetainedScanFailures)
}
}