From f699b26fa687390b73ea98f6ad41c2d44c58e6bf Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 10 Apr 2026 16:35:12 +0200 Subject: [PATCH] feat(library): add server-driven file deletion with allow_delete config --- internal/agent/daemon.go | 8 +- internal/agent/sync.go | 53 ++++ internal/agent/types.go | 47 ++-- internal/cmd/config_menu.go | 17 +- internal/cmd/daemon.go | 11 +- internal/config/config.go | 1 + internal/engine/stream_server.go | 69 ++++++ internal/library/delete.go | 148 +++++++++++ internal/library/delete_test.go | 414 +++++++++++++++++++++++++++++++ 9 files changed, 744 insertions(+), 24 deletions(-) create mode 100644 internal/library/delete.go create mode 100644 internal/library/delete_test.go diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index 225dde9..4e53c48 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -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. diff --git a/internal/agent/sync.go b/internal/agent/sync.go index 484472e..49f0e65 100644 --- a/internal/agent/sync.go +++ b/internal/agent/sync.go @@ -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. diff --git a/internal/agent/types.go b/internal/agent/types.go index e7d07d6..16ba92a 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -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"` } // --------------------------------------------------------------------------- diff --git a/internal/cmd/config_menu.go b/internal/cmd/config_menu.go index 9b1ddbf..334d815 100644 --- a/internal/cmd/config_menu.go +++ b/internal/cmd/config_menu.go @@ -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.") diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index e4abcc6..b6fb402 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -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 != "" { diff --git a/internal/config/config.go b/internal/config/config.go index cba221c..5c593d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 359d0b1..2a6c72f 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -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()) diff --git a/internal/library/delete.go b/internal/library/delete.go new file mode 100644 index 0000000..3920c6e --- /dev/null +++ b/internal/library/delete.go @@ -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 +} diff --git a/internal/library/delete_test.go b/internal/library/delete_test.go new file mode 100644 index 0000000..6b64142 --- /dev/null +++ b/internal/library/delete_test.go @@ -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") + } + }) +}