* 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>
243 lines
7.1 KiB
Go
243 lines
7.1 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
func TestParsePodcastShow(t *testing.T) {
|
|
ffprobePath := FFprobePathFromFFmpeg("ffmpeg")
|
|
if _, err := exec.LookPath(ffprobePath); err != nil {
|
|
ffprobePath = "ffprobe"
|
|
if _, err := exec.LookPath(ffprobePath); err != nil {
|
|
t.Skip("ffprobe not available")
|
|
}
|
|
}
|
|
|
|
ctx := context.Background()
|
|
got, err := parsePodcastShow(ctx, ffprobePath, "testdata/podcast_fixtures/show_a")
|
|
if err != nil {
|
|
t.Fatalf("parsePodcastShow: %v", err)
|
|
}
|
|
if got.Title != "Show A" {
|
|
t.Errorf("Title = %q, want %q", got.Title, "Show A")
|
|
}
|
|
if got.Author != "Show A Host" {
|
|
t.Errorf("Author = %q, want %q", got.Author, "Show A Host")
|
|
}
|
|
if got.Year != 2024 {
|
|
t.Errorf("Year = %d, want 2024", got.Year)
|
|
}
|
|
if len(got.Episodes) != 3 {
|
|
t.Fatalf("got %d episodes, want 3", len(got.Episodes))
|
|
}
|
|
for i, ep := range got.Episodes {
|
|
wantTrack := i + 1
|
|
if ep.Track != wantTrack {
|
|
t.Errorf("episode %d Track = %d, want %d", i, ep.Track, wantTrack)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPodcastIdentityConfidenceReflectsMetadataCompleteness(t *testing.T) {
|
|
show := &parsedPodcastShow{Title: "Tagged Show", Author: "Host", Year: 2024}
|
|
episode := parsedPodcastEpisode{Title: "Episode", Track: 3}
|
|
if got := podcastIdentityConfidence(show, episode); got != "high" {
|
|
t.Fatalf("complete metadata confidence = %q, want high", got)
|
|
}
|
|
|
|
show = &parsedPodcastShow{Title: "Tagged Show"}
|
|
episode = parsedPodcastEpisode{Title: "Episode"}
|
|
if got := podcastIdentityConfidence(show, episode); got != "medium" {
|
|
t.Fatalf("partial metadata confidence = %q, want medium", got)
|
|
}
|
|
|
|
show = &parsedPodcastShow{}
|
|
episode = parsedPodcastEpisode{}
|
|
if got := podcastIdentityConfidence(show, episode); got != "low" {
|
|
t.Fatalf("empty metadata confidence = %q, want low", got)
|
|
}
|
|
}
|
|
|
|
func TestScanPodcastFolderReturnsErrorWhenEveryReconcileFails(t *testing.T) {
|
|
root := t.TempDir()
|
|
showDir := filepath.Join(root, "bad-show")
|
|
if err := os.Mkdir(showDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir show dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(showDir, "episode.mp3"), []byte("not real audio"), 0o644); err != nil {
|
|
t.Fatalf("write fake audio: %v", err)
|
|
}
|
|
|
|
s := &Scanner{ffprobePath: "definitely-missing-ffprobe"}
|
|
err := s.ScanPodcastFolder(context.Background(), &models.MediaFolder{ID: 43, Paths: []string{root}})
|
|
if err == nil {
|
|
t.Fatal("ScanPodcastFolder returned nil, want aggregate failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "folder_id=43") {
|
|
t.Fatalf("error = %q, want folder id", err)
|
|
}
|
|
}
|
|
|
|
func TestScanPodcastFolderReturnsCanceledContext(t *testing.T) {
|
|
root := t.TempDir()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
s := &Scanner{}
|
|
err := s.ScanPodcastFolder(ctx, &models.MediaFolder{ID: 43, Paths: []string{root}})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("ScanPodcastFolder error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestListPodcastShowAudioFilesReturnsSortedAudioPaths(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "02.mp3"), []byte("audio"), 0o644); err != nil {
|
|
t.Fatalf("write audio: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("notes"), 0o644); err != nil {
|
|
t.Fatalf("write notes: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "01.flac"), []byte("audio"), 0o644); err != nil {
|
|
t.Fatalf("write audio: %v", err)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
|
|
t.Fatalf("mkdir nested: %v", err)
|
|
}
|
|
|
|
got, err := listPodcastShowAudioFiles(root)
|
|
if err != nil {
|
|
t.Fatalf("listPodcastShowAudioFiles: %v", err)
|
|
}
|
|
want := []string{
|
|
filepath.Join(root, "01.flac"),
|
|
filepath.Join(root, "02.mp3"),
|
|
}
|
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("audio files = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestListPodcastShowAudioFilesReturnsNoMediaForEmptyShow(t *testing.T) {
|
|
_, err := listPodcastShowAudioFiles(t.TempDir())
|
|
if !errors.Is(err, errFolderHasNoMedia) {
|
|
t.Fatalf("empty podcast show error = %v, want errFolderHasNoMedia", err)
|
|
}
|
|
// A missing ffprobe binary also wraps fs.ErrNotExist; the empty-folder
|
|
// signal must stay distinguishable from it.
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("empty podcast show error = %v, must not wrap os.ErrNotExist", err)
|
|
}
|
|
}
|
|
|
|
func TestListPodcastShowAudioFilesVanishedShowSignalsNoMedia(t *testing.T) {
|
|
gone := filepath.Join(t.TempDir(), "renamed-away")
|
|
|
|
_, err := listPodcastShowAudioFiles(gone)
|
|
if !errors.Is(err, errFolderHasNoMedia) {
|
|
t.Fatalf("vanished show error = %v, want errFolderHasNoMedia", err)
|
|
}
|
|
}
|
|
|
|
func TestResolvePodcastMediaItemReusesRootScopedContentID(t *testing.T) {
|
|
finder := &fakeRootContentFinder{contentID: "podcast-root-id"}
|
|
writer := &fakeFilesystemItemWriter{}
|
|
|
|
got, err := resolvePodcastMediaItem(
|
|
context.Background(),
|
|
finder,
|
|
writer,
|
|
8,
|
|
"/library/Same Show",
|
|
&parsedPodcastShow{Title: "Same Show", Year: 0, Author: "Host A"},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("resolvePodcastMediaItem: %v", err)
|
|
}
|
|
if got != "podcast-root-id" {
|
|
t.Fatalf("contentID = %q, want root-scoped id", got)
|
|
}
|
|
if len(writer.upserts) != 0 {
|
|
t.Fatalf("unexpected item upsert for existing root: %d", len(writer.upserts))
|
|
}
|
|
}
|
|
|
|
func TestResolvePodcastMediaItemCreatesNewWhenRootHasNoClaim(t *testing.T) {
|
|
finder := &fakeRootContentFinder{}
|
|
writer := &fakeFilesystemItemWriter{}
|
|
|
|
got, err := resolvePodcastMediaItem(
|
|
context.Background(),
|
|
finder,
|
|
writer,
|
|
8,
|
|
"/library/Another Same Show",
|
|
&parsedPodcastShow{Title: "Same Show", Year: 0, Author: "Host B"},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("resolvePodcastMediaItem: %v", err)
|
|
}
|
|
if got == "" {
|
|
t.Fatal("contentID is empty")
|
|
}
|
|
if len(writer.upserts) != 1 {
|
|
t.Fatalf("upserts = %d, want 1", len(writer.upserts))
|
|
}
|
|
if writer.upserts[0].ContentID != got || writer.upserts[0].Type != "podcast" {
|
|
t.Fatalf("upserted item = %+v, contentID %q", writer.upserts[0], got)
|
|
}
|
|
}
|
|
|
|
func TestApplyPodcastShowMetadataUpdatesIndexedFields(t *testing.T) {
|
|
item := &models.MediaItem{
|
|
ContentID: "podcast-1",
|
|
Type: "podcast",
|
|
Title: "Old Title",
|
|
SortTitle: "Old Title",
|
|
Year: 2023,
|
|
}
|
|
|
|
changed := applyPodcastShowMetadata(item, &parsedPodcastShow{Title: "The New Show", Year: 2024})
|
|
if !changed {
|
|
t.Fatal("applyPodcastShowMetadata reported no change")
|
|
}
|
|
if item.Title != "The New Show" {
|
|
t.Fatalf("Title = %q, want The New Show", item.Title)
|
|
}
|
|
if item.SortTitle != "New Show, The" {
|
|
t.Fatalf("SortTitle = %q, want New Show, The", item.SortTitle)
|
|
}
|
|
if item.Year != 2024 {
|
|
t.Fatalf("Year = %d, want 2024", item.Year)
|
|
}
|
|
}
|
|
|
|
func TestResolvePodcastMediaItemPropagatesRootLookupError(t *testing.T) {
|
|
wantErr := errors.New("root lookup failed")
|
|
finder := &fakeRootContentFinder{err: wantErr}
|
|
writer := &fakeFilesystemItemWriter{}
|
|
|
|
_, err := resolvePodcastMediaItem(
|
|
context.Background(),
|
|
finder,
|
|
writer,
|
|
8,
|
|
"/library/Show",
|
|
&parsedPodcastShow{Title: "Show"},
|
|
)
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("error = %v, want %v", err, wantErr)
|
|
}
|
|
if len(writer.upserts) != 0 {
|
|
t.Fatalf("upserts = %d, want 0", len(writer.upserts))
|
|
}
|
|
}
|