* 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>
439 lines
15 KiB
Go
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
|
|
}
|