unarr/internal/cmd/clean.go

343 lines
8.7 KiB
Go

package cmd
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/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
}