From 40cf251e770f6cef061fc5820eb67da443d15fc8 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Wed, 27 May 2026 08:43:36 +0200 Subject: [PATCH] feat(audiobooks): skip unchanged folders in scan path Bypasses ffprobe + DB writes for folders whose on-disk audio files match existing media_files rows by size and mtime. Stable libraries now rescan in minutes instead of hours. --- internal/catalog/item_repo.go | 22 ++++++++++ internal/scanner/audiobook_scan.go | 69 +++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/internal/catalog/item_repo.go b/internal/catalog/item_repo.go index 9788a91c..402bcd39 100644 --- a/internal/catalog/item_repo.go +++ b/internal/catalog/item_repo.go @@ -469,6 +469,28 @@ func (r *ItemRepository) GetByIDs(ctx context.Context, contentIDs []string) ([]* return scanItems(rows) } +// GetStatusByIDs returns a map of content_id → status for the requested IDs. +// Missing IDs are absent from the result rather than returned with empty values. +func (r *ItemRepository) GetStatusByIDs(ctx context.Context, ids []string) (map[string]string, error) { + if len(ids) == 0 { + return map[string]string{}, nil + } + rows, err := r.pool.Query(ctx, `SELECT content_id, status FROM media_items WHERE content_id = ANY($1)`, ids) + if err != nil { + return nil, fmt.Errorf("querying media_items statuses: %w", err) + } + defer rows.Close() + out := make(map[string]string, len(ids)) + for rows.Next() { + var id, status string + if err := rows.Scan(&id, &status); err != nil { + return nil, fmt.Errorf("scanning status row: %w", err) + } + out[id] = status + } + return out, rows.Err() +} + // GetByIDsWithAccess fetches multiple media items by content_id, filtered by // the access policy in a single query. Returns only items the viewer is // allowed to see — replaces a per-item EnsureAccessible loop alongside the diff --git a/internal/scanner/audiobook_scan.go b/internal/scanner/audiobook_scan.go index f6b1f52a..dcfca83b 100644 --- a/internal/scanner/audiobook_scan.go +++ b/internal/scanner/audiobook_scan.go @@ -57,6 +57,64 @@ func audiobookFolderUnchanged(existing []*models.MediaFile, onDisk []audiobookDi return true } +// audiobookFolderShouldSkip returns true when every audio file on disk in +// folderPath matches an existing media_files row by size + mtime AND the +// linked media_items row is in a non-unmatched status. False under any +// drift, missing row, or unmatched status — the caller then falls through +// to the full reconcile path. +// +// Errors are returned but the worker loop treats them as "do not skip". +func (s *Scanner) audiobookFolderShouldSkip(ctx context.Context, folder *models.MediaFolder, folderPath string) (bool, error) { + if s.fileRepo == nil || s.itemRepo == nil { + return false, nil + } + + entries, err := os.ReadDir(folderPath) + if err != nil { + return false, fmt.Errorf("read folder: %w", err) + } + var onDisk []audiobookDiskFile + for _, e := range entries { + if e.IsDir() || !SupportsAudioFile(e.Name()) { + continue + } + full := filepath.Join(folderPath, e.Name()) + info, statErr := os.Stat(full) + if statErr != nil { + return false, fmt.Errorf("stat %s: %w", full, statErr) + } + onDisk = append(onDisk, audiobookDiskFile{ + Path: full, + Size: info.Size(), + ModTime: normalizeFileModifiedAt(info.ModTime()), + }) + } + if len(onDisk) == 0 { + return false, nil + } + + existing, err := s.fileRepo.ListByObservedRootPath(ctx, folder.ID, folderPath) + if err != nil { + return false, fmt.Errorf("list existing files: %w", err) + } + if !audiobookFolderUnchanged(existing, onDisk) { + return false, nil + } + + contentID := existing[0].ContentID + if contentID == "" { + return false, nil + } + statuses, err := s.itemRepo.GetStatusByIDs(ctx, []string{contentID}) + if err != nil { + return false, fmt.Errorf("get item status: %w", err) + } + if strings.EqualFold(strings.TrimSpace(statuses[contentID]), "unmatched") { + return false, nil + } + return true, nil +} + // audiobookScanWorkers returns the configured number of parallel workers // for audiobook reconciliation. Defaults to 8 — high enough to keep all // cores busy on the ffprobe step (which dominates per-book wall time) @@ -136,6 +194,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF wg sync.WaitGroup processed int64 failed int64 + skipped int64 ) start := time.Now() for i := 0; i < workers; i++ { @@ -146,7 +205,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF if ctx.Err() != nil { return } - if err := s.reconcileAudiobookFolder(ctx, folder, path); err != nil { + if err := s.reconcileAudiobookFolder(ctx, folder, path, &skipped); err != nil { atomic.AddInt64(&failed, 1) slog.Warn("audiobook scan: folder failed", "folder_id", folder.ID, @@ -160,6 +219,7 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF "folder_id", folder.ID, "processed", n, "failed", atomic.LoadInt64(&failed), + "skipped", atomic.LoadInt64(&skipped), "total", len(candidates), "elapsed_sec", int(time.Since(start).Seconds()), ) @@ -181,12 +241,17 @@ func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaF "folder_id", folder.ID, "processed", atomic.LoadInt64(&processed), "failed", atomic.LoadInt64(&failed), + "skipped", atomic.LoadInt64(&skipped), "elapsed_sec", int(time.Since(start).Seconds()), ) return nil } -func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.MediaFolder, folderPath string) error { +func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.MediaFolder, folderPath string, skipped *int64) error { + if isUnchanged, err := s.audiobookFolderShouldSkip(ctx, folder, folderPath); err == nil && isUnchanged { + atomic.AddInt64(skipped, 1) + return nil + } parsed, err := parseAudiobookFolder(ctx, s.ffprobePath, folderPath) if err != nil { if errors.Is(err, os.ErrNotExist) {