Files
silo-server/internal/scanner/manga_scan.go
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

439 lines
15 KiB
Go

package scanner
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/titleutil"
"github.com/jackc/pgx/v5"
)
// ScanMangaFolder scans a manga library. It is a fork of ScanEbookFolder: the
// chapter files are kept as readable type='ebook' items exactly as the ebook
// pipeline does, while each file additionally find-or-creates a single
// type='manga' series item per series folder and links the chapter to it.
func (s *Scanner) ScanMangaFolder(ctx context.Context, folder *models.MediaFolder) error {
if s == nil || folder == nil {
return fmt.Errorf("ScanMangaFolder: nil scanner or folder")
}
return s.scanMangaPaths(ctx, folder, folder.Paths, true)
}
// scanMangaPaths mirrors scanEbookPaths exactly (root collection, worker pool,
// missing-file reconciliation) but dispatches each file to reconcileMangaFile.
func (s *Scanner) scanMangaPaths(ctx context.Context, folder *models.MediaFolder, roots []string, fullScan bool) error {
if s == nil || folder == nil {
return fmt.Errorf("scanMangaPaths: nil scanner or folder")
}
scans, err := collectEbookRootScans(ctx, folder.ID, roots)
if err != nil {
return err
}
// Every discovered file is indexed, including files under roots whose walk
// partially failed: indexing is additive and safe, only the destructive
// reconciliation below is restricted to cleanly walked roots.
var candidates []string
for i := range scans {
candidates = append(candidates, scans[i].files...)
}
if len(candidates) == 0 {
return s.reconcileMangaScan(ctx, folder, scans, nil, fullScan)
}
workers := ebookScanWorkers()
slog.InfoContext(ctx, "manga scan: starting", "component", "scanner",
"folder_id", folder.ID,
"candidates", len(candidates),
"workers", workers,
)
reportEbookScanProgress(ctx, folder.ID, len(candidates), 0, 0, 0)
ch := make(chan string, workers*2)
groupLocks := newEbookGroupLocks()
var (
wg sync.WaitGroup
processed int64
failed int64
skipped int64
cancelMu sync.Mutex
failures scanFailures
cancelErr error
)
start := time.Now()
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for path := range ch {
if ctx.Err() != nil {
return
}
if err := s.reconcileMangaFile(ctx, folder, path, &skipped, groupLocks); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
cancelMu.Lock()
if cancelErr == nil {
cancelErr = err
}
cancelMu.Unlock()
return
}
atomic.AddInt64(&failed, 1)
failures.addf("%s: %w", path, err)
slog.WarnContext(ctx, "manga scan: file failed", "component", "scanner",
"folder_id", folder.ID,
"path", path,
"error", err,
)
}
n := atomic.AddInt64(&processed, 1)
if n%500 == 0 || n == int64(len(candidates)) {
failedCount := atomic.LoadInt64(&failed)
skippedCount := atomic.LoadInt64(&skipped)
slog.InfoContext(ctx, "manga scan: progress", "component", "scanner",
"folder_id", folder.ID,
"processed", n,
"failed", failedCount,
"skipped", skippedCount,
"total", len(candidates),
"elapsed_sec", int(time.Since(start).Seconds()),
)
reportEbookScanProgress(ctx, folder.ID, len(candidates), int(n), int(failedCount), int(skippedCount))
}
}
}()
}
for _, p := range candidates {
select {
case ch <- p:
case <-ctx.Done():
close(ch)
wg.Wait()
return ctx.Err()
}
}
close(ch)
wg.Wait()
if err := ctx.Err(); err != nil {
return err
}
if cancelErr != nil {
return cancelErr
}
slog.InfoContext(ctx, "manga scan: completed", "component", "scanner",
"folder_id", folder.ID,
"processed", atomic.LoadInt64(&processed),
"failed", atomic.LoadInt64(&failed),
"skipped", atomic.LoadInt64(&skipped),
"elapsed_sec", int(time.Since(start).Seconds()),
)
if processedCount := atomic.LoadInt64(&processed); processedCount > 0 {
failedCount := atomic.LoadInt64(&failed)
skippedCount := atomic.LoadInt64(&skipped)
if failedCount > 0 && skippedCount == 0 && failedCount == processedCount {
return fmt.Errorf("manga scan failed for every attempted folder_id=%d: %w", folder.ID, failures.join())
}
}
seenPaths := make(map[string]bool, len(candidates))
for _, p := range candidates {
seenPaths[p] = true
}
return s.reconcileMangaScan(ctx, folder, scans, seenPaths, fullScan)
}
// reconcileMangaScan runs the shared ebook missing-file reconciliation (which
// removes vanished chapters) and then deletes any type='manga' series left with
// no chapters. Series items are file-less parents reconciled by chapter count,
// not file presence — catalog.ReconcileFolderMembership deliberately skips them.
func (s *Scanner) reconcileMangaScan(ctx context.Context, folder *models.MediaFolder, scans []ebookRootScan, seenPaths map[string]bool, fullScan bool) error {
if err := s.reconcileEbookScan(ctx, folder, scans, seenPaths, fullScan); err != nil {
return err
}
return s.deleteOrphanedMangaSeries(ctx, folder.ID)
}
// deleteOrphanedMangaSeries removes type='manga' series items in the folder that
// have no remaining linked chapters (e.g. once every chapter was deleted as
// missing). The cascade clears the now-empty library membership.
func (s *Scanner) deleteOrphanedMangaSeries(ctx context.Context, folderID int) error {
if s == nil || s.fileRepo == nil {
return nil
}
tx, err := s.fileRepo.Pool().Begin(ctx)
if err != nil {
return fmt.Errorf("begin orphaned manga series delete tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `
DELETE FROM media_items mi
WHERE mi.type = 'manga'
AND EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = mi.content_id AND mil.media_folder_id = $1
)
AND NOT EXISTS (
SELECT 1 FROM manga_chapters mc WHERE mc.series_content_id = mi.content_id
)
RETURNING mi.content_id
`, folderID)
if err != nil {
return fmt.Errorf("deleting orphaned manga series for folder %d: %w", folderID, err)
}
defer rows.Close()
var deletedIDs []string
for rows.Next() {
var contentID string
if err := rows.Scan(&contentID); err != nil {
return fmt.Errorf("scanning deleted manga series id: %w", err)
}
deletedIDs = append(deletedIDs, contentID)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating deleted manga series ids: %w", err)
}
if err := catalog.EnqueueSearchIndexDeletes(ctx, tx, deletedIDs); err != nil {
return fmt.Errorf("enqueueing catalog search manga series delete: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit orphaned manga series delete tx: %w", err)
}
if len(deletedIDs) > 0 {
slog.InfoContext(ctx, "manga scan: removed orphaned series", "component", "scanner", "folder_id", folderID, "deleted", len(deletedIDs))
}
return nil
}
// reconcileMangaFile indexes one .cbz/.cbr chapter file: it keeps the file as a
// readable type='ebook' chapter item (exactly as reconcileEbookFile does), then
// find-or-creates the single type='manga' series item for the chapter's series
// folder and links the chapter to it with its parsed index/volume.
func (s *Scanner) reconcileMangaFile(ctx context.Context, folder *models.MediaFolder, filePath string, skipped *int64, groupLocks *ebookGroupLocks) error {
info, err := os.Stat(filePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("stat manga file %s: %w", filePath, err)
}
size := info.Size()
modifiedAt := normalizeFileModifiedAt(info.ModTime())
_, isUnchanged, skipErr := s.ebookFileShouldSkip(ctx, folder, filePath, size, modifiedAt)
if skipErr != nil {
slog.WarnContext(ctx, "manga scan: skip-check failed, falling through", "component", "scanner",
"folder_id", folder.ID,
"path", filePath,
"error", skipErr,
)
} else if isUnchanged {
atomic.AddInt64(skipped, 1)
return nil
}
parsed, err := parseEbookFile(filePath)
if err != nil {
return fmt.Errorf("parse manga file %s: %w", filePath, err)
}
if parsed.Title == "" {
parsed.Title = ebookTitleFromPath(filePath)
}
seriesName := mangaSeriesFromPath(filePath)
if seriesName == "" {
seriesName = ebookTitleFromPath(filePath)
}
stem := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
vol, idx, has := mangaIndexForFile(stem, seriesName)
// 1. Keep the file as a readable type='ebook' chapter item, exactly as the
// ebook pipeline does (cover + page count + media file + membership).
chapterGroupKey := ebookContentGroupKey(&parsed, filePath)
chapterID, err := func() (string, error) {
unlock := groupLocks.lock(chapterGroupKey)
defer unlock()
contentID, curated, err := s.upsertEbookMediaItem(ctx, folder.ID, filePath, &parsed, chapterGroupKey)
if err != nil {
return "", fmt.Errorf("upsert manga chapter item: %w", err)
}
if err := s.upsertEbookMediaFile(ctx, folder, contentID, filePath, size, modifiedAt, &parsed, chapterGroupKey); err != nil {
return "", fmt.Errorf("upsert manga chapter file: %w", err)
}
if err := applyEbookLocalCover(ctx, s.itemRepo, s.imageCacher, contentID, filePath, &parsed); err != nil {
slog.WarnContext(ctx, "manga scan: local cover upload failed", "component", "scanner",
"folder_id", folder.ID,
"content_id", contentID,
"path", filePath,
"error", err,
)
}
if err := s.upsertEbookPeople(ctx, contentID, &parsed, curated); err != nil {
return "", fmt.Errorf("upsert manga chapter people: %w", err)
}
if err := insertEbookLibraryMembership(ctx, s.fileRepo.Pool(), contentID, folder.ID); err != nil {
return "", fmt.Errorf("upsert manga chapter library membership: %w", err)
}
return contentID, nil
}()
if err != nil {
return err
}
// 2. Find-or-create the single type='manga' series item for this folder and
// link the chapter to it.
seriesID, err := s.findOrCreateMangaSeries(ctx, folder.ID, seriesName, groupLocks)
if err != nil {
return fmt.Errorf("find-or-create manga series: %w", err)
}
if seriesID != "" {
// The series item carries no media file of its own, so it must be given a
// library membership explicitly (the chapter path gets this via its file
// reconcile). Without it the library-scoped catalog browse, which joins
// media_item_libraries, would never surface the series card. The insert is
// ON CONFLICT DO NOTHING, so re-scans never duplicate the membership.
if err := insertEbookLibraryMembership(ctx, s.fileRepo.Pool(), seriesID, folder.ID); err != nil {
return fmt.Errorf("upsert manga series library membership: %w", err)
}
idxPtr, volOut := mangaChapterWrite(vol, idx, has)
if err := upsertMangaChapter(ctx, s.fileRepo.Pool(), chapterID, seriesID, idxPtr, volOut); err != nil {
return fmt.Errorf("link manga chapter to series: %w", err)
}
}
slog.DebugContext(ctx, "manga scan: indexed", "component", "scanner",
"folder_id", folder.ID,
"chapter_id", chapterID,
"series_id", seriesID,
"series", seriesName,
"path", filePath,
)
return nil
}
// mangaSeriesProvider is the provider namespace under which a manga series
// item's content-group key is recorded in media_item_provider_ids. The table's
// UNIQUE (provider, provider_id, item_type) constraint guarantees exactly one
// type='manga' series item per group key, which is what makes re-scans
// idempotent across processes.
const mangaSeriesProvider = "manga_series"
// findOrCreateMangaSeries resolves the single type='manga' series item for the
// given series name in the folder, creating it on first sight. It is idempotent:
// re-scanning any chapter of the same series resolves to the same series
// content_id. The group-key lock serializes creation across this process's
// worker goroutines; the SELECT-after-conflicting-INSERT recovers the winner's
// content_id if another process raced us.
func (s *Scanner) findOrCreateMangaSeries(ctx context.Context, folderID int, seriesName string, groupLocks *ebookGroupLocks) (string, error) {
if s.itemRepo == nil {
return "", fmt.Errorf("itemRepo not configured on Scanner")
}
if s.fileRepo == nil {
return "", fmt.Errorf("fileRepo not configured on Scanner")
}
groupKey := mangaSeriesGroupKey(folderID, seriesName)
if groupKey == "" {
return "", nil
}
unlock := groupLocks.lock(groupKey)
defer unlock()
if existing, err := s.lookupMangaSeries(ctx, groupKey); err != nil {
return "", err
} else if existing != "" {
return existing, nil
}
id, err := idgen.NextID()
if err != nil {
return "", fmt.Errorf("generate manga series content_id: %w", err)
}
title := strings.TrimSpace(seriesName)
item := &models.MediaItem{
ContentID: id,
Type: "manga",
// Explicit "pending" mirrors the ebook chapter path: enrichment promotes
// it to "matched". This is the "needs metadata" status.
Status: "pending",
Title: title,
SortTitle: titleutil.DeriveDefaultSortTitle(title),
}
if err := s.itemRepo.Upsert(ctx, item); err != nil {
return "", fmt.Errorf("create manga series item: %w", err)
}
tag, err := s.fileRepo.Pool().Exec(ctx, `
INSERT INTO media_item_provider_ids (content_id, provider, provider_id, item_type)
VALUES ($1, $2, $3, 'manga')
ON CONFLICT (provider, provider_id, item_type) DO NOTHING
`, id, mangaSeriesProvider, groupKey)
if err != nil {
return "", fmt.Errorf("record manga series key: %w", err)
}
if tag.RowsAffected() == 0 {
// Another process created the series first; our freshly minted item is a
// dangling orphan. Delete it and adopt the winner so no duplicate series
// survives.
winner, lookupErr := s.lookupMangaSeries(ctx, groupKey)
if lookupErr != nil {
return "", lookupErr
}
if winner != "" && winner != id {
tx, txErr := s.fileRepo.Pool().Begin(ctx)
if txErr != nil {
return "", fmt.Errorf("begin duplicate manga series delete tx: %w", txErr)
}
_, delErr := tx.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, id)
if delErr != nil {
_ = tx.Rollback(ctx)
slog.WarnContext(ctx, "manga scan: failed to delete duplicate series item", "component", "scanner",
"folder_id", folderID,
"content_id", id,
"error", delErr,
)
} else if eventErr := catalog.EnqueueSearchIndexDelete(ctx, tx, id); eventErr != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("enqueue catalog search duplicate manga series delete: %w", eventErr)
} else if commitErr := tx.Commit(ctx); commitErr != nil {
return "", fmt.Errorf("commit duplicate manga series delete tx: %w", commitErr)
}
return winner, nil
}
}
return id, nil
}
// lookupMangaSeries returns the content_id of the type='manga' series item
// already recorded for the group key, or "" if none exists.
func (s *Scanner) lookupMangaSeries(ctx context.Context, groupKey string) (string, error) {
var id string
err := s.fileRepo.Pool().QueryRow(ctx, `
SELECT content_id
FROM media_item_provider_ids
WHERE provider = $1 AND provider_id = $2 AND item_type = 'manga'
LIMIT 1
`, mangaSeriesProvider, groupKey).Scan(&id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("lookup manga series by key: %w", err)
}
return id, nil
}