diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index a6e892a..e4abcc6 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -401,20 +401,15 @@ func runDaemonStart() error { }() // Start auto-scan goroutine - scanPath := cfg.Library.ScanPath - if scanPath == "" { - scanPath = cfg.Download.Dir - } - if scanPath != "" && cfg.Library.AutoScan { - scanCfg := cfg - scanCfg.Library.ScanPath = scanPath + scanPaths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath) + if len(scanPaths) > 0 && cfg.Library.AutoScan { scanInterval := 24 * time.Hour if cfg.Library.ScanInterval != "" { if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 { scanInterval = parsed } } - go runAutoScan(ctx, scanCfg, scanInterval, agentClient, d.ScanNow) + go runAutoScan(ctx, cfg, scanInterval, agentClient, d.ScanNow, scanPaths) } // Start reporter only for stream task handling @@ -491,8 +486,10 @@ func formatSpeedLog(bps int64) string { } // runAutoScan runs a library scan + sync on a timer or on-demand via scanNow channel. -func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, ac *agent.Client, scanNow <-chan struct{}) { - log.Printf("[auto-scan] enabled: every %s, path: %s", interval, cfg.Library.ScanPath) +// It scans all provided paths and syncs each independently so stale-item cleanup +// is scoped to the correct directory prefix on the server. +func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, ac *agent.Client, scanNow <-chan struct{}, scanPaths []string) { + log.Printf("[auto-scan] enabled: every %s, paths: %v", interval, scanPaths) select { case <-time.After(30 * time.Second): @@ -507,7 +504,7 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, log.Printf("[auto-scan] panic recovered: %v", r) } }() - log.Printf("[auto-scan] starting scan of %s", cfg.Library.ScanPath) + log.Printf("[auto-scan] starting scan of %v", scanPaths) existing, _ := library.LoadCache() @@ -516,49 +513,67 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, workers = 8 } - cache, err := library.Scan(ctx, cfg.Library.ScanPath, existing, library.ScanOptions{ + scanOpts := library.ScanOptions{ Workers: workers, FFprobePath: cfg.Library.FFprobePath, Incremental: existing != nil, - }) - if err != nil { - log.Printf("[auto-scan] scan failed: %v", err) - return - } - - if err := library.SaveCache(cache); err != nil { - log.Printf("[auto-scan] save cache failed: %v", err) - return - } - - items := library.BuildSyncItems(cache) - if len(items) == 0 { - log.Printf("[auto-scan] no items to sync") - return } + // Scan each path independently and sync per path so the server can + // scope stale-item deletion to the correct directory prefix. const batchSize = 100 - syncStartedAt := time.Now().UTC().Format(time.RFC3339) - for i := 0; i < len(items); i += batchSize { - end := i + batchSize - if end > len(items) { - end = len(items) - } - isLast := end >= len(items) + totalSynced := 0 + var mergedItems []library.LibraryItem - _, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{ - Items: items[i:end], - ScanPath: cache.Path, - IsLastBatch: isLast, - SyncStartedAt: syncStartedAt, - }) + for _, scanPath := range scanPaths { + cache, err := library.Scan(ctx, scanPath, existing, scanOpts) if err != nil { - log.Printf("[auto-scan] sync failed: %v", err) - return + log.Printf("[auto-scan] scan failed for %s: %v", scanPath, err) + continue + } + mergedItems = append(mergedItems, cache.Items...) + + items := library.BuildSyncItems(cache) + if len(items) == 0 { + log.Printf("[auto-scan] no items under %s", scanPath) + continue + } + + syncStartedAt := time.Now().UTC().Format(time.RFC3339) + for i := 0; i < len(items); i += batchSize { + end := i + batchSize + if end > len(items) { + end = len(items) + } + isLast := end >= len(items) + + _, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{ + Items: items[i:end], + ScanPath: scanPath, + IsLastBatch: isLast, + SyncStartedAt: syncStartedAt, + }) + if err != nil { + log.Printf("[auto-scan] sync failed for %s: %v", scanPath, err) + break + } + } + totalSynced += len(items) + } + + // Save merged cache for incremental scanning next time. + if len(mergedItems) > 0 { + mergedCache := &library.LibraryCache{ + ScannedAt: time.Now().UTC().Format(time.RFC3339), + Path: scanPaths[0], + Items: mergedItems, + } + if err := library.SaveCache(mergedCache); err != nil { + log.Printf("[auto-scan] save cache failed: %v", err) } } - log.Printf("[auto-scan] synced %d items", len(items)) + log.Printf("[auto-scan] synced %d items across %d path(s)", totalSynced, len(scanPaths)) } doScan() diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index 3633028..df66a18 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -41,11 +41,16 @@ to see available quality upgrades.`, } if len(args) == 0 { cfg := loadConfig() - if cfg.Library.ScanPath != "" { - args = append(args, cfg.Library.ScanPath) - } else { - return fmt.Errorf("usage: unarr scan \n\nProvide a media folder to scan") + paths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath) + if len(paths) == 0 { + return fmt.Errorf("usage: unarr scan \n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'") } + for _, p := range paths { + if err := runScan(p, workers, ffprobe, noSync); err != nil { + return err + } + } + return nil } return runScan(args[0], workers, ffprobe, noSync) }, diff --git a/internal/library/paths.go b/internal/library/paths.go new file mode 100644 index 0000000..88752bf --- /dev/null +++ b/internal/library/paths.go @@ -0,0 +1,55 @@ +package library + +import ( + "path/filepath" + "strings" +) + +// ResolveScanPaths returns a deduplicated list of directories to scan. +// Always includes dlDir, moviesDir, tvDir (when non-empty). +// Adds scanPath if non-empty. +// Removes paths that are subdirectories of other paths in the list, +// since a parent walk already covers them. +func ResolveScanPaths(dlDir, moviesDir, tvDir, scanPath string) []string { + raw := make([]string, 0, 4) + for _, p := range []string{dlDir, moviesDir, tvDir, scanPath} { + if p != "" { + raw = append(raw, filepath.Clean(p)) + } + } + return deduplicatePaths(raw) +} + +// deduplicatePaths removes duplicate paths and paths that are subdirectories +// of another path already present in the list. +func deduplicatePaths(paths []string) []string { + // Remove exact duplicates first. + seen := make(map[string]bool, len(paths)) + unique := make([]string, 0, len(paths)) + for _, p := range paths { + if !seen[p] { + seen[p] = true + unique = append(unique, p) + } + } + + // Remove paths that are subdirs of another path in the list. + result := make([]string, 0, len(unique)) + for _, p := range unique { + isChild := false + for _, other := range unique { + if other == p { + continue + } + rel, err := filepath.Rel(other, p) + if err == nil && rel != "." && !strings.HasPrefix(rel, "..") { + isChild = true + break + } + } + if !isChild { + result = append(result, p) + } + } + return result +}