- Rename Go module path github.com/torrentclaw/torrentclaw-cli → github.com/torrentclaw/unarr - Update all imports, ldflags, scripts, docs, and Docker config - Add GitHub Actions release workflow (goreleaser on tag push)
344 lines
8.7 KiB
Go
344 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
|
|
}
|
|
|