package cmd import ( "bufio" "fmt" "os" "path/filepath" "strings" "time" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/torrentclaw/torrentclaw-cli/internal/agent" "github.com/torrentclaw/torrentclaw-cli/internal/config" "github.com/torrentclaw/torrentclaw-cli/internal/ui" ) func newCleanCmd() *cobra.Command { var ( dryRun bool all bool yes bool ) cmd := &cobra.Command{ Use: "clean", Short: "Remove temporary files, logs, and cached data", Long: `Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting. By default, cleans: - Log files (unarr.log, unarr.err.log) - Daemon state file (daemon.state.json) - Stale usenet resume files older than 7 days (.progress, .nzb cache) - Stream temp directory (/tmp/unarr-stream/) - Upgrade temp files (/tmp/unarr-download-*.tmp) - Stale atomic-write temp files (.tmp) Recent usenet resume files (< 7 days) are kept to preserve download progress for paused or interrupted downloads. With --all, also removes: - ALL usenet resume files (including recent ones) - The entire data directory (~/.local/share/unarr/) Never removes: - Configuration file (config.toml) - Downloaded media files - Partial torrent or debrid downloads (stored in your download dir)`, Example: ` unarr clean # Show files and confirm before removing unarr clean --dry-run # Show what would be removed (no prompt) unarr clean --yes # Skip confirmation unarr clean --all # Also remove the data directory`, RunE: func(cmd *cobra.Command, args []string) error { return runClean(dryRun, all, yes) }, } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be removed without deleting") cmd.Flags().BoolVar(&all, "all", false, "also remove the entire data directory") cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompt") return cmd } type cleanTarget struct { path string description string isDir bool isGlob bool } // foundEntry represents a file or directory found during scanning. type foundEntry struct { path string desc string size int64 isDir bool } func runClean(dryRun, all, yes bool) error { bold := color.New(color.Bold) green := color.New(color.FgGreen) yellow := color.New(color.FgYellow) dim := color.New(color.FgHiBlack) // Check if daemon is running if state := agent.ReadState(); state != nil && state.PID > 0 { if agent.IsProcessAlive(state.PID) { return fmt.Errorf("daemon is running (PID %d) — stop it first with Ctrl+C or 'unarr stop'", state.PID) } } dataDir := config.DataDir() tmpDir := os.TempDir() // With --all, remove the entire data directory (includes logs, state, resume). // Without --all, target individual files + stale resume files only. var targets []cleanTarget if all { targets = []cleanTarget{ {dataDir, "data directory", true, false}, } } else { targets = []cleanTarget{ {filepath.Join(dataDir, "unarr.log"), "daemon log", false, false}, {filepath.Join(dataDir, "unarr.err.log"), "daemon error log", false, false}, {filepath.Join(dataDir, "daemon.state.json"), "daemon state", false, false}, {filepath.Join(dataDir, "daemon.state.json.tmp"), "daemon state temp", false, false}, // Resume files handled separately below (stale only) } } // Temp targets apply regardless of --all targets = append(targets, cleanTarget{filepath.Join(tmpDir, "unarr-stream"), "stream temp data", true, false}, cleanTarget{filepath.Join(tmpDir, "unarr-download-*.tmp"), "upgrade temp files", false, true}, cleanTarget{config.FilePath() + ".tmp", "config temp", false, false}, ) // Phase 1: Scan and collect what exists var found []foundEntry var totalFiles int var totalBytes int64 for _, t := range targets { if t.isGlob { matches, _ := filepath.Glob(t.path) for _, m := range matches { size := fileSize(m) totalFiles++ totalBytes += size found = append(found, foundEntry{m, t.description, size, false}) } continue } info, err := os.Stat(t.path) if err != nil { continue } if t.isDir { files, bytes := dirStats(t.path) if files == 0 { continue } totalFiles += files totalBytes += bytes found = append(found, foundEntry{ path: t.path + "/", desc: fmt.Sprintf("%s (%d files)", t.description, files), size: bytes, isDir: true, }) } else { totalFiles++ totalBytes += info.Size() found = append(found, foundEntry{t.path, t.description, info.Size(), false}) } } // Resume files: only scan individually when NOT --all (--all already includes them via dataDir) var resumeSkipped int if !all { resumeDir := filepath.Join(dataDir, "resume") var resumeFound []foundEntry resumeFound, resumeSkipped = scanResumeFiles(resumeDir, false) for _, e := range resumeFound { totalFiles++ totalBytes += e.size found = append(found, e) } } // Phase 2: Show what was found fmt.Println() bold.Println(" unarr clean") fmt.Println() if totalFiles == 0 { dim.Println(" Nothing to clean.") fmt.Println() return nil } fmt.Println(" Files to remove:") fmt.Println() for _, e := range found { display := shortenHome(e.path) fmt.Printf(" %s ", display) dim.Printf("(%s, %s)\n", e.desc, ui.FormatBytes(e.size)) } fmt.Println() bold.Printf(" Total: %d file(s), %s\n", totalFiles, ui.FormatBytes(totalBytes)) if resumeSkipped > 0 { dim.Printf(" Kept %d recent resume file(s) (< 7 days) — use --all to include\n", resumeSkipped) } fmt.Println() // Phase 3: Dry run stops here if dryRun { yellow.Println(" Dry run — nothing will be deleted.") fmt.Println() return nil } // Phase 4: Ask for confirmation (unless --yes) if !yes { fmt.Print(" Proceed? [y/N] ") scanner := bufio.NewScanner(os.Stdin) scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) if answer != "y" && answer != "yes" { fmt.Println() yellow.Println(" Aborted.") fmt.Println() return nil } fmt.Println() } // Phase 5: Delete for _, e := range found { if e.isDir { os.RemoveAll(strings.TrimSuffix(e.path, "/")) } else { os.Remove(e.path) } display := shortenHome(e.path) green.Print(" ✓ ") fmt.Println(display) } fmt.Println() green.Printf(" ✓ Removed %d file(s), %s freed\n", totalFiles, ui.FormatBytes(totalBytes)) fmt.Println() return nil } // scanResumeFiles scans the resume directory for cleanable files. // If all is true, returns all files. Otherwise only stale files (>7 days). func scanResumeFiles(resumeDir string, all bool) (found []foundEntry, skipped int) { entries, err := os.ReadDir(resumeDir) if err != nil { return nil, 0 } const staleAge = 7 * 24 * time.Hour for _, e := range entries { if e.IsDir() { continue } info, err := e.Info() if err != nil { continue } isStale := time.Since(info.ModTime()) > staleAge if !all && !isStale { skipped++ continue } path := filepath.Join(resumeDir, e.Name()) desc := "stale resume file" if all && !isStale { desc = "resume file" } found = append(found, foundEntry{path, desc, info.Size(), false}) } return found, skipped } func shortenHome(path string) string { home, _ := os.UserHomeDir() if home != "" { return strings.Replace(path, home, "~", 1) } return path } func dirStats(dir string) (int, int64) { var files int var bytes int64 filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if !info.IsDir() { files++ bytes += info.Size() } return nil }) return files, bytes } func fileSize(path string) int64 { info, err := os.Stat(path) if err != nil { return 0 } return info.Size() } // CleanableBytes calculates the total size of files that would be safe to clean // right now. Called from the daemon heartbeat to report cleanable space. // Excludes daemon.state.json (actively written while daemon runs) and // recent resume files (< 7 days, needed for download resume). func CleanableBytes() int64 { dataDir := config.DataDir() tmpDir := os.TempDir() var total int64 // Logs total += fileSize(filepath.Join(dataDir, "unarr.log")) total += fileSize(filepath.Join(dataDir, "unarr.err.log")) // Stale resume files only (>7 days) resumeFound, _ := scanResumeFiles(filepath.Join(dataDir, "resume"), false) for _, e := range resumeFound { total += e.size } // Stream temp _, streamBytes := dirStats(filepath.Join(tmpDir, "unarr-stream")) total += streamBytes // Upgrade temp files matches, _ := filepath.Glob(filepath.Join(tmpDir, "unarr-download-*.tmp")) for _, m := range matches { total += fileSize(m) } // Config temp total += fileSize(config.FilePath() + ".tmp") return total }