feat(scan): always scan downloads + organize dirs, deduplicate child paths
ResolveScanPaths() collects downloads.dir, organize.movies_dir, organize.tv_shows_dir, and library.scan_path (if set), then removes paths that are subdirectories of a parent already in the list. This ensures the daemon and CLI scan all configured dirs without relying solely on scan_path being set.
This commit is contained in:
parent
b2ed81ee74
commit
db316726fd
3 changed files with 122 additions and 47 deletions
|
|
@ -401,20 +401,15 @@ func runDaemonStart() error {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start auto-scan goroutine
|
// Start auto-scan goroutine
|
||||||
scanPath := cfg.Library.ScanPath
|
scanPaths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath)
|
||||||
if scanPath == "" {
|
if len(scanPaths) > 0 && cfg.Library.AutoScan {
|
||||||
scanPath = cfg.Download.Dir
|
|
||||||
}
|
|
||||||
if scanPath != "" && cfg.Library.AutoScan {
|
|
||||||
scanCfg := cfg
|
|
||||||
scanCfg.Library.ScanPath = scanPath
|
|
||||||
scanInterval := 24 * time.Hour
|
scanInterval := 24 * time.Hour
|
||||||
if cfg.Library.ScanInterval != "" {
|
if cfg.Library.ScanInterval != "" {
|
||||||
if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 {
|
if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 {
|
||||||
scanInterval = parsed
|
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
|
// 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.
|
// 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{}) {
|
// It scans all provided paths and syncs each independently so stale-item cleanup
|
||||||
log.Printf("[auto-scan] enabled: every %s, path: %s", interval, cfg.Library.ScanPath)
|
// 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 {
|
select {
|
||||||
case <-time.After(30 * time.Second):
|
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] 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()
|
existing, _ := library.LoadCache()
|
||||||
|
|
||||||
|
|
@ -516,28 +513,32 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration,
|
||||||
workers = 8
|
workers = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
cache, err := library.Scan(ctx, cfg.Library.ScanPath, existing, library.ScanOptions{
|
scanOpts := library.ScanOptions{
|
||||||
Workers: workers,
|
Workers: workers,
|
||||||
FFprobePath: cfg.Library.FFprobePath,
|
FFprobePath: cfg.Library.FFprobePath,
|
||||||
Incremental: existing != nil,
|
Incremental: existing != nil,
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[auto-scan] scan failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := library.SaveCache(cache); err != nil {
|
// Scan each path independently and sync per path so the server can
|
||||||
log.Printf("[auto-scan] save cache failed: %v", err)
|
// scope stale-item deletion to the correct directory prefix.
|
||||||
return
|
const batchSize = 100
|
||||||
|
totalSynced := 0
|
||||||
|
var mergedItems []library.LibraryItem
|
||||||
|
|
||||||
|
for _, scanPath := range scanPaths {
|
||||||
|
cache, err := library.Scan(ctx, scanPath, existing, scanOpts)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[auto-scan] scan failed for %s: %v", scanPath, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
mergedItems = append(mergedItems, cache.Items...)
|
||||||
|
|
||||||
items := library.BuildSyncItems(cache)
|
items := library.BuildSyncItems(cache)
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
log.Printf("[auto-scan] no items to sync")
|
log.Printf("[auto-scan] no items under %s", scanPath)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchSize = 100
|
|
||||||
syncStartedAt := time.Now().UTC().Format(time.RFC3339)
|
syncStartedAt := time.Now().UTC().Format(time.RFC3339)
|
||||||
for i := 0; i < len(items); i += batchSize {
|
for i := 0; i < len(items); i += batchSize {
|
||||||
end := i + batchSize
|
end := i + batchSize
|
||||||
|
|
@ -548,17 +549,31 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration,
|
||||||
|
|
||||||
_, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
|
_, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
|
||||||
Items: items[i:end],
|
Items: items[i:end],
|
||||||
ScanPath: cache.Path,
|
ScanPath: scanPath,
|
||||||
IsLastBatch: isLast,
|
IsLastBatch: isLast,
|
||||||
SyncStartedAt: syncStartedAt,
|
SyncStartedAt: syncStartedAt,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[auto-scan] sync failed: %v", err)
|
log.Printf("[auto-scan] sync failed for %s: %v", scanPath, err)
|
||||||
return
|
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()
|
doScan()
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,16 @@ to see available quality upgrades.`,
|
||||||
}
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
if cfg.Library.ScanPath != "" {
|
paths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath)
|
||||||
args = append(args, cfg.Library.ScanPath)
|
if len(paths) == 0 {
|
||||||
} else {
|
return fmt.Errorf("usage: unarr scan <path>\n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'")
|
||||||
return fmt.Errorf("usage: unarr scan <path>\n\nProvide a media folder to scan")
|
|
||||||
}
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
if err := runScan(p, workers, ffprobe, noSync); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return runScan(args[0], workers, ffprobe, noSync)
|
return runScan(args[0], workers, ffprobe, noSync)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
55
internal/library/paths.go
Normal file
55
internal/library/paths.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue