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:
parent
3d6142a62e
commit
35e5298f23
9 changed files with 578 additions and 1 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
14
README.md
14
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 <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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
internal/agent/process_unix.go
Normal file
11
internal/agent/process_unix.go
Normal 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
|
||||
}
|
||||
25
internal/agent/process_windows.go
Normal file
25
internal/agent/process_windows.go
Normal 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
344
internal/cmd/clean.go
Normal 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
177
internal/cmd/clean_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue