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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user