feat(library): add server-driven file deletion with allow_delete config

This commit is contained in:
Deivid Soto 2026-04-10 16:35:12 +02:00
parent 8ad8a5ea47
commit f699b26fa6
9 changed files with 744 additions and 24 deletions

View file

@ -18,9 +18,11 @@ type DaemonConfig struct {
AgentName string
Version string
DownloadDir string
StreamPort int // port for the HTTP stream server
LanIP string // LAN IP (reported in sync for stream URL resolution)
TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution)
StreamPort int // port for the HTTP stream server
LanIP string // LAN IP (reported in sync for stream URL resolution)
TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution)
CanDelete bool // library.allow_delete is enabled
ScanPaths []string // configured scan paths for file deletion validation
}
// Daemon manages agent registration and the sync loop.

View file

@ -4,6 +4,7 @@ import (
"context"
"log"
"runtime"
"sync"
"sync/atomic"
"time"
)
@ -34,12 +35,22 @@ type SyncClient struct {
OnSyncSuccess func() // called after each successful sync (e.g. to update state file)
GetFreeSlots func() int
GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks
// OnDeleteFiles is called when the server requests file deletion from disk.
// It should delete the files and return the IDs of successfully deleted items.
OnDeleteFiles func(items []LibraryDeleteRequest) []int
// SyncNow triggers an immediate sync (e.g., on task completion).
SyncNow chan struct{}
watching atomic.Bool
interval atomic.Int64 // stored as nanoseconds
// pendingDeleteConfirmed holds item IDs to report as deleted in the next sync.
pendingDeleteMu sync.Mutex
pendingDeleteConfirmed []int
// deleteInFlight tracks item IDs currently being processed or awaiting confirmation.
// Prevents the same file from being passed to OnDeleteFiles multiple times.
deleteInFlight map[int]struct{}
}
// NewSyncClient creates a sync client.
@ -129,6 +140,7 @@ func (sc *SyncClient) buildRequest() SyncRequest {
StreamPort: sc.cfg.StreamPort,
LanIP: sc.cfg.LanIP,
TailscaleIP: sc.cfg.TailscaleIP,
CanDelete: sc.cfg.CanDelete,
}
if sc.GetTaskStates != nil {
req.Tasks = sc.GetTaskStates()
@ -142,6 +154,18 @@ func (sc *SyncClient) buildRequest() SyncRequest {
if sc.GetFreeSlots != nil {
req.FreeSlots = sc.GetFreeSlots()
}
// Flush confirmed deletions from previous cycle.
// Once flushed, remove IDs from deleteInFlight — the server will stop sending
// them after this sync, so deduplication protection is no longer needed.
sc.pendingDeleteMu.Lock()
if len(sc.pendingDeleteConfirmed) > 0 {
req.DeleteConfirmed = sc.pendingDeleteConfirmed
for _, id := range sc.pendingDeleteConfirmed {
delete(sc.deleteInFlight, id)
}
sc.pendingDeleteConfirmed = nil
}
sc.pendingDeleteMu.Unlock()
return req
}
@ -176,6 +200,35 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
if resp.Scan && sc.OnScan != nil {
sc.OnScan()
}
// File deletions requested by the server — deduplicate against in-flight items
if len(resp.FilesToDelete) > 0 && sc.OnDeleteFiles != nil {
sc.pendingDeleteMu.Lock()
if sc.deleteInFlight == nil {
sc.deleteInFlight = make(map[int]struct{})
}
var newItems []LibraryDeleteRequest
for _, item := range resp.FilesToDelete {
if _, inFlight := sc.deleteInFlight[item.ItemID]; !inFlight {
newItems = append(newItems, item)
sc.deleteInFlight[item.ItemID] = struct{}{}
}
}
sc.pendingDeleteMu.Unlock()
if len(newItems) > 0 {
// Run deletions off the sync goroutine — disk I/O must not block the
// next sync tick. Confirmations are picked up on the next regular cycle.
go func(items []LibraryDeleteRequest) {
confirmed := sc.OnDeleteFiles(items)
if len(confirmed) > 0 {
sc.pendingDeleteMu.Lock()
sc.pendingDeleteConfirmed = append(sc.pendingDeleteConfirmed, confirmed...)
sc.pendingDeleteMu.Unlock()
}
}(newItems)
}
}
}
// runWakeListener holds a long-poll connection to /api/internal/agent/wake.

View file

@ -312,19 +312,21 @@ type LibrarySyncResponse struct {
// SyncRequest is sent by the CLI periodically to synchronize state with the server.
// Contains the CLI's full execution state — the server responds with pending actions.
type SyncRequest struct {
AgentID string `json:"agentId"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Name string `json:"name,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
FreeSlots int `json:"freeSlots"`
Tasks []TaskState `json:"tasks"`
AgentID string `json:"agentId"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Name string `json:"name,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
FreeSlots int `json:"freeSlots"`
Tasks []TaskState `json:"tasks"`
CanDelete bool `json:"canDelete"` // library.allow_delete is enabled
DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk
}
// ControlAction represents a server-side control signal for a task.
@ -334,14 +336,21 @@ type ControlAction struct {
DeleteFiles bool `json:"deleteFiles,omitempty"`
}
// LibraryDeleteRequest is a server-side request to delete a file from disk.
type LibraryDeleteRequest struct {
ItemID int `json:"itemId"`
FilePath string `json:"filePath"`
}
// SyncResponse is returned by the server with all pending actions for the CLI.
type SyncResponse struct {
NewTasks []Task `json:"newTasks,omitempty"`
Controls []ControlAction `json:"controls,omitempty"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
Watching bool `json:"watching"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Scan bool `json:"scan,omitempty"`
NewTasks []Task `json:"newTasks,omitempty"`
Controls []ControlAction `json:"controls,omitempty"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
Watching bool `json:"watching"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Scan bool `json:"scan,omitempty"`
FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"`
}
// ---------------------------------------------------------------------------

View file

@ -14,7 +14,7 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
var configCategories = []string{"downloads", "organization", "notifications", "device", "region", "connection", "advanced"}
var configCategories = []string{"downloads", "organization", "library", "notifications", "device", "region", "connection", "advanced"}
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
@ -25,6 +25,7 @@ func newConfigCmd() *cobra.Command {
Categories:
downloads Download directory, method, speed limits, concurrency
organization Auto-sort into Movies / TV Shows folders
library Library scan settings and file deletion permissions
notifications Desktop notifications
device Agent name
region Country and language
@ -95,6 +96,7 @@ func runConfigMenu(category string) error {
Options(
huh.NewOption("Downloads — directory, method, speed limits", "downloads"),
huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"),
huh.NewOption("Library — scan settings & file deletion", "library"),
huh.NewOption("Notifications — desktop notifications", "notifications"),
huh.NewOption("Device — agent name", "device"),
huh.NewOption("Region — country & language", "region"),
@ -131,6 +133,8 @@ func runCategory(cfg *config.Config, category string) error {
return configDownloads(cfg)
case "organization":
return configOrganization(cfg)
case "library":
return configLibrary(cfg)
case "notifications":
return configNotifications(cfg)
case "device":
@ -311,6 +315,17 @@ func configConnection(cfg *config.Config) error {
).Run()
}
func configLibrary(cfg *config.Config) error {
return huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Allow file deletion from web UI?").
Description("When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered.").
Value(&cfg.Library.AllowDelete),
),
).Run()
}
func configAdvanced(_ *config.Config) error {
// Sync intervals are adaptive (3s watching, 60s idle) — no user-facing config needed.
fmt.Println("No advanced settings to configure. Sync intervals are automatic.")

View file

@ -138,6 +138,8 @@ func runDaemonStart() error {
StreamPort: cfg.Download.StreamPort,
LanIP: engine.LanIP(),
TailscaleIP: engine.TailscaleIP(),
CanDelete: cfg.Library.AllowDelete,
ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath),
}
// Create HTTP client — single communication channel
@ -302,6 +304,13 @@ func runDaemonStart() error {
}
}
// Wire: sync receives file deletion requests from the server
if cfg.Library.AllowDelete && len(daemonCfg.ScanPaths) > 0 {
sc.OnDeleteFiles = func(items []agent.LibraryDeleteRequest) []int {
return library.DeleteFiles(items, daemonCfg.ScanPaths)
}
}
// Wire: sync receives stream requests for completed downloads
d.OnStreamRequested = func(sr agent.StreamRequest) {
if streamSrv.CurrentTaskID() == sr.TaskID {
@ -401,7 +410,7 @@ func runDaemonStart() error {
}()
// Start auto-scan goroutine
scanPaths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath)
scanPaths := daemonCfg.ScanPaths
if len(scanPaths) > 0 && cfg.Library.AutoScan {
scanInterval := 24 * time.Hour
if cfg.Library.ScanInterval != "" {

View file

@ -73,6 +73,7 @@ type LibraryConfig struct {
BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
}
// Default returns a Config with sensible defaults.

View file

@ -72,6 +72,7 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/stream", ss.handler)
mux.HandleFunc("/health", ss.healthHandler)
mux.HandleFunc("/playlist.m3u", ss.playlistHandler)
// SO_REUSEADDR allows immediate rebind if the port is in TIME_WAIT (e.g. after agent restart)
lc := net.ListenConfig{
@ -274,6 +275,74 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp) //nolint:errcheck
}
// playlistHandler generates an M3U playlist for VLC with #EXTVLCOPT language hints.
// Query params: audioLangs (comma-sep), subLangs (comma-sep), resumeSec, title, streamUrl.
// If streamUrl is omitted, uses the current best stream URL.
//
// VLC fetches this playlist and applies the EXTVLCOPT directives automatically,
// enabling automatic audio/subtitle track selection on all VLC platforms (desktop + mobile).
func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) {
// CORS — handle preflight before doing any work (consistent with handler)
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
q := r.URL.Query()
// Sanitize query params: strip CR/LF to prevent M3U directive injection.
sanitize := func(s string) string {
s = strings.ReplaceAll(s, "\n", "")
s = strings.ReplaceAll(s, "\r", "")
return s
}
audioLangs := sanitize(q.Get("audioLangs"))
subLangs := sanitize(q.Get("subLangs"))
resumeSec := sanitize(q.Get("resumeSec"))
title := sanitize(q.Get("title"))
streamURL := q.Get("streamUrl")
// Only accept http(s) URLs to prevent file:// or other URI schemes in the playlist.
if streamURL != "" && !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") {
streamURL = ""
}
if streamURL == "" {
streamURL = ss.url
}
if streamURL == "" {
http.Error(w, "no active stream", http.StatusNotFound)
return
}
if title == "" {
title = "TorrentClaw Stream"
}
var b strings.Builder
b.WriteString("#EXTM3U\n")
b.WriteString(fmt.Sprintf("#EXTINF:-1,%s\n", title))
if audioLangs != "" {
b.WriteString(fmt.Sprintf("#EXTVLCOPT:audio-language=%s\n", audioLangs))
}
if subLangs != "" {
b.WriteString(fmt.Sprintf("#EXTVLCOPT:sub-language=%s\n", subLangs))
}
if resumeSec != "" && resumeSec != "0" {
b.WriteString(fmt.Sprintf("#EXTVLCOPT:start-time=%s\n", resumeSec))
}
b.WriteString("#EXTVLCOPT:network-caching=30000\n")
b.WriteString(streamURL + "\n")
w.Header().Set("Content-Type", "audio/x-mpegurl")
w.Header().Set("Content-Disposition", `inline; filename="stream.m3u"`)
w.Header().Set("Cache-Control", "no-cache")
fmt.Fprint(w, b.String()) //nolint:errcheck
}
func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
ss.lastActivity.Store(time.Now().UnixNano())

148
internal/library/delete.go Normal file
View file

@ -0,0 +1,148 @@
package library
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/torrentclaw/unarr/internal/agent"
)
// DeleteFiles deletes the given library items from disk and cleans up empty
// parent directories within the configured scan paths.
//
// Safety rules (all must pass before os.Remove is called):
// 1. filePath must be an absolute path.
// 2. filePath must be within one of the configured scanPaths.
// 3. Empty parent directories are removed up to (but not including) the
// scan path root and only if they are not the scan path itself.
//
// Returns the IDs of items successfully deleted.
func DeleteFiles(items []agent.LibraryDeleteRequest, scanPaths []string) []int {
// Sanitize scan paths: reject empty or non-absolute entries.
safe := make([]string, 0, len(scanPaths))
for _, sp := range scanPaths {
if filepath.IsAbs(sp) {
safe = append(safe, sp)
} else {
log.Printf("library: ignoring non-absolute scan path: %q", sp)
}
}
if len(safe) == 0 {
log.Printf("library: no valid scan paths configured — refusing to delete")
return nil
}
confirmed := make([]int, 0, len(items))
for _, item := range items {
if err := deleteOne(item.FilePath, safe); err != nil {
log.Printf("library: delete item %d (%q): %v", item.ItemID, item.FilePath, err)
continue
}
log.Printf("library: deleted item %d: %s", item.ItemID, item.FilePath)
confirmed = append(confirmed, item.ItemID)
}
return confirmed
}
func deleteOne(filePath string, scanPaths []string) error {
if !filepath.IsAbs(filePath) {
return fmt.Errorf("path is not absolute: %q", filePath)
}
clean := filepath.Clean(filePath)
// Resolve symlinks before validation to prevent traversal via symlinks.
real, err := filepath.EvalSymlinks(clean)
if err != nil {
if os.IsNotExist(err) {
// File already gone — idempotent success.
pruneEmptyDirs(filepath.Dir(clean), scanPaths)
return nil
}
return fmt.Errorf("resolve symlinks: %w", err)
}
// Security: resolved file must be within one of the configured scan paths.
if !isWithinScanPaths(real, scanPaths) {
return fmt.Errorf("path %q (resolved: %q) is outside all configured scan paths — refusing to delete", clean, real)
}
// Remove the file (idempotent: not-exist is not an error).
if err := os.Remove(real); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove file: %w", err)
}
// Clean up empty parent directories, stopping at the scan path root.
pruneEmptyDirs(filepath.Dir(real), scanPaths)
return nil
}
// isWithinScanPaths returns true if p is a child of any scan path.
func isWithinScanPaths(p string, scanPaths []string) bool {
for _, sp := range scanPaths {
sp = filepath.Clean(sp)
rel, err := filepath.Rel(sp, p)
if err != nil {
continue
}
// rel must not be "." (exact match = root itself) and must not start with ".."
if rel != "." && !strings.HasPrefix(rel, "..") {
return true
}
}
return false
}
// pruneEmptyDirs walks upward from dir, removing empty directories until it
// reaches a scan path root (which is never removed).
// Max 10 levels to guard against infinite loops on unexpected path shapes.
func pruneEmptyDirs(dir string, scanPaths []string) {
const maxLevels = 10
for i := 0; i < maxLevels; i++ {
dir = filepath.Clean(dir)
// Single pass: stop if dir is a scan root or outside all scan paths.
if !dirEligibleForPrune(dir, scanPaths) {
return
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) > 0 {
return // non-empty or unreadable — stop
}
if err := os.Remove(dir); err != nil {
log.Printf("library: prune dir %s: %v", dir, err)
return
}
log.Printf("library: removed empty dir: %s", dir)
dir = filepath.Dir(dir)
}
}
// dirEligibleForPrune returns true if dir is a strict child of any scan path
// (i.e. it is inside a scan path but is not the scan root itself).
// Combines the former isScanPathRoot + isWithinScanPaths checks into one loop.
func dirEligibleForPrune(dir string, scanPaths []string) bool {
for _, sp := range scanPaths {
sp = filepath.Clean(sp)
if sp == dir {
return false // dir IS the scan root — never remove it
}
rel, err := filepath.Rel(sp, dir)
if err != nil {
continue
}
if rel != "." && !strings.HasPrefix(rel, "..") {
return true
}
}
return false
}

View file

@ -0,0 +1,414 @@
package library
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/torrentclaw/unarr/internal/agent"
)
// ---------------------------------------------------------------------------
// isWithinScanPaths
// ---------------------------------------------------------------------------
func TestIsWithinScanPaths(t *testing.T) {
tests := []struct {
name string
path string
scanPaths []string
want bool
}{
{
name: "file inside scan path",
path: "/media/movies/Inception.mkv",
scanPaths: []string{"/media/movies"},
want: true,
},
{
name: "file in subdirectory of scan path",
path: "/media/movies/2024/Inception/Inception.mkv",
scanPaths: []string{"/media/movies"},
want: true,
},
{
name: "file at scan path root itself",
path: "/media/movies",
scanPaths: []string{"/media/movies"},
want: false, // rel == "."
},
{
name: "file outside all scan paths",
path: "/tmp/evil.mkv",
scanPaths: []string{"/media/movies", "/media/shows"},
want: false,
},
{
name: "dotdot traversal attempt",
path: "/media/movies/../../../etc/passwd",
scanPaths: []string{"/media/movies"},
want: false,
},
{
name: "multiple scan paths file in second",
path: "/media/shows/Breaking.Bad.S01E01.mkv",
scanPaths: []string{"/media/movies", "/media/shows"},
want: true,
},
{
name: "empty scan paths",
path: "/media/movies/file.mkv",
scanPaths: []string{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isWithinScanPaths(tt.path, tt.scanPaths)
if got != tt.want {
t.Errorf("isWithinScanPaths(%q, %v) = %v, want %v", tt.path, tt.scanPaths, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// dirEligibleForPrune
// ---------------------------------------------------------------------------
func TestDirEligibleForPrune(t *testing.T) {
tests := []struct {
name string
dir string
scanPaths []string
want bool
}{
{
name: "scan root itself is NOT eligible",
dir: "/media/movies",
scanPaths: []string{"/media/movies"},
want: false,
},
{
name: "subdirectory IS eligible",
dir: "/media/movies/2024",
scanPaths: []string{"/media/movies"},
want: true,
},
{
name: "parent of scan path is NOT eligible",
dir: "/media",
scanPaths: []string{"/media/movies"},
want: false,
},
{
name: "trailing slash normalization — root not eligible",
dir: "/media/movies",
scanPaths: []string{"/media/movies/"},
want: false, // filepath.Clean removes trailing slash
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dirEligibleForPrune(tt.dir, tt.scanPaths)
if got != tt.want {
t.Errorf("dirEligibleForPrune(%q, %v) = %v, want %v", tt.dir, tt.scanPaths, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// deleteOne
// ---------------------------------------------------------------------------
func TestDeleteOne(t *testing.T) {
t.Run("delete existing file inside scan path", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "movie.mkv")
if err := os.WriteFile(file, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := deleteOne(file, []string{root}); err != nil {
t.Fatalf("deleteOne returned error: %v", err)
}
if _, err := os.Stat(file); !os.IsNotExist(err) {
t.Error("file should have been deleted")
}
})
t.Run("reject relative path", func(t *testing.T) {
root := t.TempDir()
err := deleteOne("relative/path.mkv", []string{root})
if err == nil {
t.Fatal("expected error for relative path")
}
if got := err.Error(); got != `path is not absolute: "relative/path.mkv"` {
t.Errorf("unexpected error message: %s", got)
}
})
t.Run("reject path outside scan paths", func(t *testing.T) {
scanRoot := t.TempDir()
outsideDir := t.TempDir()
file := filepath.Join(outsideDir, "secret.txt")
if err := os.WriteFile(file, []byte("secret"), 0644); err != nil {
t.Fatal(err)
}
err := deleteOne(file, []string{scanRoot})
if err == nil {
t.Fatal("expected error for path outside scan paths")
}
// File must NOT have been deleted.
if _, statErr := os.Stat(file); statErr != nil {
t.Error("file outside scan path should NOT have been deleted")
}
})
t.Run("file already deleted is idempotent", func(t *testing.T) {
root := t.TempDir()
// Reference a file that does not exist.
file := filepath.Join(root, "gone.mkv")
if err := deleteOne(file, []string{root}); err != nil {
t.Fatalf("expected idempotent success, got error: %v", err)
}
})
t.Run("symlink pointing outside scan path is rejected", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require elevated privileges on Windows")
}
scanRoot := t.TempDir()
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "real.mkv")
if err := os.WriteFile(outsideFile, []byte("real"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(scanRoot, "link.mkv")
if err := os.Symlink(outsideFile, link); err != nil {
t.Fatal(err)
}
err := deleteOne(link, []string{scanRoot})
if err == nil {
t.Fatal("expected error: symlink target is outside scan paths")
}
// The real file must NOT have been deleted.
if _, statErr := os.Stat(outsideFile); statErr != nil {
t.Error("symlink target outside scan path should NOT have been deleted")
}
})
t.Run("symlink pointing inside scan path is allowed", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require elevated privileges on Windows")
}
scanRoot := t.TempDir()
subdir := filepath.Join(scanRoot, "sub")
if err := os.Mkdir(subdir, 0755); err != nil {
t.Fatal(err)
}
realFile := filepath.Join(subdir, "real.mkv")
if err := os.WriteFile(realFile, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
link := filepath.Join(scanRoot, "link.mkv")
if err := os.Symlink(realFile, link); err != nil {
t.Fatal(err)
}
if err := deleteOne(link, []string{scanRoot}); err != nil {
t.Fatalf("deleteOne returned error: %v", err)
}
// The real file should have been deleted (os.Remove on resolved path).
if _, statErr := os.Stat(realFile); !os.IsNotExist(statErr) {
t.Error("resolved target inside scan path should have been deleted")
}
})
}
// ---------------------------------------------------------------------------
// pruneEmptyDirs
// ---------------------------------------------------------------------------
func TestPruneEmptyDirs(t *testing.T) {
t.Run("empty parent dir is removed", func(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "show")
if err := os.Mkdir(sub, 0755); err != nil {
t.Fatal(err)
}
pruneEmptyDirs(sub, []string{root})
if _, err := os.Stat(sub); !os.IsNotExist(err) {
t.Error("empty subdirectory should have been removed")
}
// Scan root must still exist.
if _, err := os.Stat(root); err != nil {
t.Error("scan path root should NOT have been removed")
}
})
t.Run("non-empty parent dir is NOT removed", func(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "show")
if err := os.Mkdir(sub, 0755); err != nil {
t.Fatal(err)
}
// Put a file inside so it's not empty.
if err := os.WriteFile(filepath.Join(sub, "keep.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
pruneEmptyDirs(sub, []string{root})
if _, err := os.Stat(sub); err != nil {
t.Error("non-empty directory should NOT have been removed")
}
})
t.Run("stops at scan path root", func(t *testing.T) {
root := t.TempDir()
// Create an empty dir that IS the scan root.
// pruneEmptyDirs should refuse to remove it.
pruneEmptyDirs(root, []string{root})
if _, err := os.Stat(root); err != nil {
t.Error("scan path root should never be removed")
}
})
t.Run("multi-level cleanup", func(t *testing.T) {
root := t.TempDir()
deep := filepath.Join(root, "a", "b", "c")
if err := os.MkdirAll(deep, 0755); err != nil {
t.Fatal(err)
}
pruneEmptyDirs(deep, []string{root})
// All three levels (a, a/b, a/b/c) should be removed.
for _, dir := range []string{
filepath.Join(root, "a", "b", "c"),
filepath.Join(root, "a", "b"),
filepath.Join(root, "a"),
} {
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("directory should have been removed: %s", dir)
}
}
// Scan root must still exist.
if _, err := os.Stat(root); err != nil {
t.Error("scan path root should NOT have been removed")
}
})
}
// ---------------------------------------------------------------------------
// DeleteFiles (integration)
// ---------------------------------------------------------------------------
func TestDeleteFiles(t *testing.T) {
t.Run("multiple items some valid some invalid", func(t *testing.T) {
root := t.TempDir()
outsideDir := t.TempDir()
goodFile := filepath.Join(root, "good.mkv")
if err := os.WriteFile(goodFile, []byte("ok"), 0644); err != nil {
t.Fatal(err)
}
outsideFile := filepath.Join(outsideDir, "outside.mkv")
if err := os.WriteFile(outsideFile, []byte("nope"), 0644); err != nil {
t.Fatal(err)
}
items := []agent.LibraryDeleteRequest{
{ItemID: 1, FilePath: goodFile}, // valid → deleted
{ItemID: 2, FilePath: "relative/bad.mkv"}, // relative → rejected
{ItemID: 3, FilePath: outsideFile}, // outside scan paths → rejected
{ItemID: 4, FilePath: filepath.Join(root, "gone.mkv")}, // not-exist → idempotent success
}
confirmed := DeleteFiles(items, []string{root})
// Items 1 and 4 should succeed. Item 2 (relative) and 3 (outside) should fail.
want := map[int]bool{1: true, 4: true}
got := make(map[int]bool, len(confirmed))
for _, id := range confirmed {
got[id] = true
}
if len(got) != len(want) {
t.Fatalf("confirmed = %v, want IDs %v", confirmed, want)
}
for id := range want {
if !got[id] {
t.Errorf("expected item %d to be confirmed", id)
}
}
// outsideFile must NOT have been deleted.
if _, err := os.Stat(outsideFile); err != nil {
t.Error("file outside scan paths should NOT have been deleted")
}
// good.mkv should be deleted.
if _, err := os.Stat(goodFile); !os.IsNotExist(err) {
t.Error("good.mkv should have been deleted")
}
})
t.Run("empty scan paths returns nil", func(t *testing.T) {
items := []agent.LibraryDeleteRequest{
{ItemID: 1, FilePath: "/some/file.mkv"},
}
confirmed := DeleteFiles(items, []string{})
if confirmed != nil {
t.Errorf("expected nil, got %v", confirmed)
}
})
t.Run("all relative scan paths returns nil", func(t *testing.T) {
items := []agent.LibraryDeleteRequest{
{ItemID: 1, FilePath: "/some/file.mkv"},
}
confirmed := DeleteFiles(items, []string{"relative/path", "another/relative"})
if confirmed != nil {
t.Errorf("expected nil, got %v", confirmed)
}
})
t.Run("mixed absolute and relative scan paths uses only absolute", func(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "movie.mkv")
if err := os.WriteFile(file, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
items := []agent.LibraryDeleteRequest{
{ItemID: 10, FilePath: file},
}
confirmed := DeleteFiles(items, []string{"relative/bad", root})
if len(confirmed) != 1 || confirmed[0] != 10 {
t.Errorf("confirmed = %v, want [10]", confirmed)
}
if _, err := os.Stat(file); !os.IsNotExist(err) {
t.Error("file should have been deleted via the absolute scan path")
}
})
}