feat(library): add server-driven file deletion with allow_delete config
This commit is contained in:
parent
8ad8a5ea47
commit
f699b26fa6
9 changed files with 744 additions and 24 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
148
internal/library/delete.go
Normal 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
|
||||
}
|
||||
414
internal/library/delete_test.go
Normal file
414
internal/library/delete_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue