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.
This commit is contained in:
Deivid Soto 2026-03-29 11:04:51 +02:00
parent 3d6142a62e
commit 35e5298f23
9 changed files with 578 additions and 1 deletions

View file

@ -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`)

View file

@ -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 <shell>` | 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:

View file

@ -40,6 +40,7 @@ type Daemon struct {
// Callbacks for state tracking (set by cmd/daemon.go)
GetActiveCount func() int
GetCleanableBytes func() int64
// Exposed tickers for hot-reload
PollTicker *time.Ticker

View file

@ -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
}

View file

@ -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
}

344
internal/cmd/clean.go Normal file
View file

@ -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
}

177
internal/cmd/clean_test.go Normal file
View file

@ -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)
}
}
}

View file

@ -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) {

View file

@ -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,