package cmd import ( "bufio" "context" "encoding/gob" "fmt" "os" "strings" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/utils/pl" "github.com/spf13/cobra" ) var ( fullScan bool subprocess bool targets []string targetFile string ) func init() { scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps") scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)") scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")") scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)") rootCmd.AddCommand(scanCmd) } var scanCmd = &cobra.Command{ Use: "scan", Short: "Scan music folder", Long: "Scan music folder for updates", Run: func(cmd *cobra.Command, args []string) { runScanner(cmd.Context()) }, } func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) { for status := range pl.ReadOrDone(ctx, progress) { if status.Warning != "" { log.Warn(ctx, "Scan warning", "error", status.Warning) } if status.Error != "" { log.Error(ctx, "Scan error", "error", status.Error) } // Discard the progress status, we only care about errors } if fullScan { log.Info("Finished full rescan") } else { log.Info("Finished rescan") } } func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) { encoder := gob.NewEncoder(os.Stdout) for status := range pl.ReadOrDone(ctx, progress) { err := encoder.Encode(status) if err != nil { log.Error(ctx, "Failed to encode status", err) } } } func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) pls := core.NewPlaylists(ds) // Parse targets from command line or file var scanTargets []model.ScanTarget var err error if targetFile != "" { scanTargets, err = readTargetsFromFile(targetFile) if err != nil { log.Fatal(ctx, "Failed to read targets from file", err) } log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets)) } else if len(targets) > 0 { scanTargets, err = model.ParseTargets(targets) if err != nil { log.Fatal(ctx, "Failed to parse targets", err) } log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets)) } progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets) if err != nil { log.Fatal(ctx, "Failed to scan", err) } // Wait for the scanner to finish if subprocess { trackScanAsSubprocess(ctx, progress) } else { trackScanInteractively(ctx, progress) } } // readTargetsFromFile reads scan targets from a file, one per line. // Each line should be in the format "libraryID:folderPath". // Empty lines and lines starting with # are ignored. func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) { file, err := os.Open(filePath) if err != nil { return nil, fmt.Errorf("failed to open target file: %w", err) } defer file.Close() var targetStrings []string scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Skip empty lines and comments if line == "" { continue } targetStrings = append(targetStrings, line) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("failed to read target file: %w", err) } return model.ParseTargets(targetStrings) }