From 35e5298f233f0c182498b995a221bafbea0c139c Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sun, 29 Mar 2026 11:04:51 +0200 Subject: [PATCH] feat: add clean command to remove temp files, logs, and cached data Adds `unarr clean` with interactive confirmation, --dry-run, --yes, and --all flags. Safely skips recent usenet resume files (<7 days) to preserve download progress. Includes platform-specific PID detection (Unix signal 0 / Windows heartbeat heuristic), CleanableBytes callback for future heartbeat reporting, and uses shared ui.FormatBytes. --- CHANGELOG.md | 1 + README.md | 14 ++ internal/agent/daemon.go | 3 +- internal/agent/process_unix.go | 11 + internal/agent/process_windows.go | 25 +++ internal/cmd/clean.go | 344 ++++++++++++++++++++++++++++++ internal/cmd/clean_test.go | 177 +++++++++++++++ internal/cmd/daemon.go | 1 + internal/cmd/root.go | 3 + 9 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 internal/agent/process_unix.go create mode 100644 internal/agent/process_windows.go create mode 100644 internal/cmd/clean.go create mode 100644 internal/cmd/clean_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index ca81aad..4306bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Clean command to remove temp files, logs, and cached data (`unarr clean`) - Daemon mode with background download management (`unarr start`) - One-shot download command (`unarr download`) - Stream to media player (`unarr stream`) diff --git a/README.md b/README.md index 1419ce7..f09d308 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ unarr start |---------|-------------| | `unarr stats` | Show catalog statistics | | `unarr doctor` | Diagnose configuration and connectivity | +| `unarr clean` | Remove temporary files, logs, and cached data | | `unarr self-update` | Update unarr to the latest version | | `unarr version` | Show version info | | `unarr completion ` | Generate shell completion scripts | @@ -219,6 +220,19 @@ unarr self-update --force # reinstall even if up to date `unarr doctor` checks: config file, API key, server connectivity (with latency), agent registration, download directory, disk space, and version. +## Clean + +Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting. + +```bash +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 +``` + +**Cleans:** log files, daemon state, stale usenet resume files (> 7 days), stream temp data, upgrade temp files, and stale atomic-write temps. Recent resume files are kept to preserve download progress for paused or interrupted downloads. Never removes your config file, downloaded media, or partial torrent/debrid downloads. + ## Alias (optional) Create a shell alias for shorter commands: diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index fe57e85..b8a9017 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -39,7 +39,8 @@ type Daemon struct { heartbeatFailures int // Callbacks for state tracking (set by cmd/daemon.go) - GetActiveCount func() int + GetActiveCount func() int + GetCleanableBytes func() int64 // Exposed tickers for hot-reload PollTicker *time.Ticker diff --git a/internal/agent/process_unix.go b/internal/agent/process_unix.go new file mode 100644 index 0000000..7612915 --- /dev/null +++ b/internal/agent/process_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package agent + +import "syscall" + +// IsProcessAlive checks if a process with the given PID is running. +// On Unix, sends signal 0 which checks existence without affecting the process. +func IsProcessAlive(pid int) bool { + return syscall.Kill(pid, 0) == nil +} diff --git a/internal/agent/process_windows.go b/internal/agent/process_windows.go new file mode 100644 index 0000000..d093237 --- /dev/null +++ b/internal/agent/process_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package agent + +import ( + "os" + "time" +) + +// IsProcessAlive checks if a process with the given PID is running. +// On Windows, os.FindProcess + a zero-timeout wait is used since +// signal 0 is not supported. +func IsProcessAlive(pid int) bool { + p, err := os.FindProcess(pid) + if err != nil { + return false + } + // On Windows, FindProcess always succeeds. Use the state file's + // last heartbeat as a heuristic: if it's recent, the process is alive. + state := ReadState() + if state == nil || state.PID != pid { + return false + } + return time.Since(state.LastHeartbeat) < 2*time.Minute +} diff --git a/internal/cmd/clean.go b/internal/cmd/clean.go new file mode 100644 index 0000000..bfac761 --- /dev/null +++ b/internal/cmd/clean.go @@ -0,0 +1,344 @@ +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 +} + diff --git a/internal/cmd/clean_test.go b/internal/cmd/clean_test.go new file mode 100644 index 0000000..0d918c6 --- /dev/null +++ b/internal/cmd/clean_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDirStats_Empty(t *testing.T) { + dir := t.TempDir() + files, bytes := dirStats(dir) + if files != 0 || bytes != 0 { + t.Errorf("expected 0 files 0 bytes, got %d files %d bytes", files, bytes) + } +} + +func TestDirStats_WithFiles(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "a.log"), []byte("hello"), 0o644) + os.WriteFile(filepath.Join(dir, "b.log"), []byte("world!"), 0o644) + + files, bytes := dirStats(dir) + if files != 2 { + t.Errorf("expected 2 files, got %d", files) + } + if bytes != 11 { + t.Errorf("expected 11 bytes, got %d", bytes) + } +} + +func TestDirStats_NonExistent(t *testing.T) { + files, bytes := dirStats("/nonexistent-dir-12345") + if files != 0 || bytes != 0 { + t.Errorf("expected 0/0 for nonexistent dir, got %d/%d", files, bytes) + } +} + +func TestFileSize_Exists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.txt") + os.WriteFile(path, []byte("1234567890"), 0o644) + + size := fileSize(path) + if size != 10 { + t.Errorf("expected 10, got %d", size) + } +} + +func TestFileSize_NonExistent(t *testing.T) { + size := fileSize("/nonexistent-file-12345") + if size != 0 { + t.Errorf("expected 0 for nonexistent file, got %d", size) + } +} + + +func TestRunClean_DryRun(t *testing.T) { + err := runClean(true, false, false) + if err != nil { + t.Logf("runClean dry-run returned: %v (may be expected)", err) + } +} + +func TestShortenHome(t *testing.T) { + home, _ := os.UserHomeDir() + if home == "" { + t.Skip("no home dir") + } + + result := shortenHome(filepath.Join(home, ".local", "share", "unarr", "unarr.log")) + if !strings.HasPrefix(result, "~") { + t.Errorf("expected path starting with ~, got %q", result) + } + if strings.Contains(result, home) { + t.Errorf("home dir not shortened in %q", result) + } +} + +func TestShortenHome_NoHome(t *testing.T) { + result := shortenHome("/tmp/some/file.txt") + if result != "/tmp/some/file.txt" { + t.Errorf("expected unchanged path, got %q", result) + } +} + +func TestScanResumeFiles_Empty(t *testing.T) { + dir := t.TempDir() + found, skipped := scanResumeFiles(dir, false) + if len(found) != 0 || skipped != 0 { + t.Errorf("expected all zeros, got found=%d skipped=%d", len(found), skipped) + } +} + +func TestScanResumeFiles_NonExistent(t *testing.T) { + found, skipped := scanResumeFiles("/nonexistent-resume-dir", false) + if len(found) != 0 || skipped != 0 { + t.Errorf("expected all zeros for nonexistent dir") + } +} + +func TestScanResumeFiles_OnlyStale(t *testing.T) { + dir := t.TempDir() + + // Create a stale file (>7 days) + stalePath := filepath.Join(dir, "old-task.progress") + os.WriteFile(stalePath, []byte("stale data"), 0o644) + staleTime := time.Now().Add(-8 * 24 * time.Hour) + os.Chtimes(stalePath, staleTime, staleTime) + + // Create a fresh file (<7 days) + os.WriteFile(filepath.Join(dir, "new-task.progress"), []byte("fresh"), 0o644) + + // Default mode: only stale files + found, skipped := scanResumeFiles(dir, false) + if len(found) != 1 { + t.Errorf("expected 1 stale file, got %d", len(found)) + } + if skipped != 1 { + t.Errorf("expected 1 skipped (fresh), got %d", skipped) + } + if len(found) != 1 || found[0].path != stalePath { + t.Errorf("expected stale file in found, got %v", found) + } +} + +func TestScanResumeFiles_AllMode(t *testing.T) { + dir := t.TempDir() + + // Create stale + fresh files + os.WriteFile(filepath.Join(dir, "old-task.progress"), []byte("stale"), 0o644) + staleTime := time.Now().Add(-8 * 24 * time.Hour) + os.Chtimes(filepath.Join(dir, "old-task.progress"), staleTime, staleTime) + + os.WriteFile(filepath.Join(dir, "new-task.nzb"), []byte("fresh"), 0o644) + + // --all mode: everything + found, skipped := scanResumeFiles(dir, true) + if len(found) != 2 { + t.Errorf("expected 2 files with --all, got %d", len(found)) + } + if skipped != 0 { + t.Errorf("expected 0 skipped with --all, got %d", skipped) + } +} + +func TestScanResumeFiles_DescLabels(t *testing.T) { + dir := t.TempDir() + + // Stale file + stalePath := filepath.Join(dir, "old.progress") + os.WriteFile(stalePath, []byte("x"), 0o644) + staleTime := time.Now().Add(-10 * 24 * time.Hour) + os.Chtimes(stalePath, staleTime, staleTime) + + // Fresh file + freshPath := filepath.Join(dir, "new.nzb") + os.WriteFile(freshPath, []byte("y"), 0o644) + + // Default: stale only + found, _ := scanResumeFiles(dir, false) + if len(found) != 1 || found[0].desc != "stale resume file" { + t.Errorf("expected desc 'stale resume file', got %q", found[0].desc) + } + + // All mode: fresh file should say "resume file" + found, _ = scanResumeFiles(dir, true) + for _, e := range found { + if e.path == freshPath && e.desc != "resume file" { + t.Errorf("expected desc 'resume file' for fresh file, got %q", e.desc) + } + if e.path == stalePath && e.desc != "stale resume file" { + t.Errorf("expected desc 'stale resume file' for stale file, got %q", e.desc) + } + } +} diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 5b1bea3..1f34f64 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -219,6 +219,7 @@ func runDaemonStart() error { // Wire state tracking d.GetActiveCount = manager.ActiveCount + d.GetCleanableBytes = CleanableBytes // Wire: server-side signals -> manager actions + stream tasks reporter.SetCancelHandler(func(taskID string) { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 51c77c1..7207cac 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -100,6 +100,8 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`, statsCmd.GroupID = "system" doctorCmd := newDoctorCmd() doctorCmd.GroupID = "system" + cleanCmd := newCleanCmd() + cleanCmd.GroupID = "system" selfUpdateCmd := newSelfUpdateCmd() selfUpdateCmd.GroupID = "system" versionCmd := newVersionCmd() @@ -128,6 +130,7 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`, // System & Diagnostics statsCmd, doctorCmd, + cleanCmd, selfUpdateCmd, versionCmd, completionCmd,