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.
This commit is contained in:
RXWatcher
2026-05-27 08:43:36 +02:00
parent 58c7334cf5
commit 40cf251e77
2 changed files with 89 additions and 2 deletions
+22
View File
@@ -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
+67 -2
View File
@@ -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) {