From 3def6216fc7715d1eb455ac68f236187809b21e1 Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:05:34 +0530 Subject: [PATCH 01/11] feat(arrs): stuck import queue cleanup for all arrs Add automatic cleanup of imports that get stuck on known *arr errors, configurable via a grace period and per-message action rules (remove / blocklist / blocklist + search). Runs on the cleanup interval or on demand from the Queue page. Squashed from the feature's iterative development: initial implementation, API-response config persistence, removal of the manual cleanup dropdown and dead endpoint, and the Queue Cleanup rename with friendlier UI copy. --- .../components/config/ArrsConfigSection.tsx | 139 +++++- frontend/src/types/config.ts | 11 + internal/api/types.go | 6 + internal/arrs/clients/sportarr.go | 12 +- internal/arrs/service.go | 4 +- internal/arrs/worker/stuck_cleanup.go | 429 ++++++++++++++++++ internal/arrs/worker/worker.go | 12 +- internal/config/manager.go | 66 +++ 8 files changed, 673 insertions(+), 6 deletions(-) create mode 100644 internal/arrs/worker/stuck_cleanup.go diff --git a/frontend/src/components/config/ArrsConfigSection.tsx b/frontend/src/components/config/ArrsConfigSection.tsx index 4a73343b2..bddff26c6 100644 --- a/frontend/src/components/config/ArrsConfigSection.tsx +++ b/frontend/src/components/config/ArrsConfigSection.tsx @@ -1,7 +1,13 @@ import { AlertTriangle, Plus, Save, Trash2, Webhook } from "lucide-react"; import { useEffect, useState } from "react"; import { useRegisterArrsWebhooks } from "../../hooks/useApi"; -import type { ArrsConfig, ArrsInstanceConfig, ArrsType, ConfigResponse } from "../../types/config"; +import type { + ArrsConfig, + ArrsInstanceConfig, + ArrsType, + ConfigResponse, + StuckCleanupAction, +} from "../../types/config"; import { LoadingSpinner } from "../ui/LoadingSpinner"; import { ArrsInstanceCard } from "./ArrsInstanceCard"; @@ -209,6 +215,24 @@ export function ArrsConfigSection({ handleFormChange("queue_cleanup_allowlist", newList); }; + const handleRemoveStuckPattern = (index: number) => { + const newList = [...(formData.stuck_cleanup_rules || [])]; + newList.splice(index, 1); + handleFormChange("stuck_cleanup_rules", newList); + }; + + const handleToggleStuckPattern = (index: number) => { + const newList = [...(formData.stuck_cleanup_rules || [])]; + newList[index] = { ...newList[index], enabled: !newList[index].enabled }; + handleFormChange("stuck_cleanup_rules", newList); + }; + + const handleSetStuckRuleAction = (index: number, action: StuckCleanupAction) => { + const newList = [...(formData.stuck_cleanup_rules || [])]; + newList[index] = { ...newList[index], action }; + handleFormChange("stuck_cleanup_rules", newList); + }; + const handleSave = async () => { if (!onUpdate || validationErrors.length > 0) return; setSaveError(null); @@ -501,6 +525,119 @@ export function ArrsConfigSection({ )} + +
+ + {/* Stuck Import Cleanup — own section */} +
+

+ Queue Cleanup +

+
+
+ +
+
+
+ Auto-Resolve Stuck Imports +
+

+ Automatically clears imports that get stuck for a known reason — runs on a + schedule or on demand from the Queue page. +

+
+ handleFormChange("stuck_cleanup_enabled", e.target.checked)} + disabled={isReadOnly} + /> +
+ + {(formData.stuck_cleanup_enabled ?? false) && ( +
+
+ + Stuck Grace Period + +
+ + handleFormChange( + "stuck_cleanup_grace_period_minutes", + Number.parseInt(e.target.value, 10) || 5, + ) + } + min={1} + disabled={isReadOnly} + /> + + min + +
+
+ Minutes an import must stay stuck before a rule acts (e.g. 1–5). Brief errors + the *arr clears on its own are ignored. +
+
+ +
+
+ Stuck Error Rules +
+

+ Match an *arr error message to an action: remove, blocklist, or blocklist + + search. +

+
+ {(formData.stuck_cleanup_rules || []).map((rule, index) => ( +
+ handleToggleStuckPattern(index)} + disabled={isReadOnly} + /> + + {rule.message} + + + +
+ ))} +
+
+
+ )}
)} diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 7f418c552..f67e1c8e1 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -757,6 +757,17 @@ export interface ArrsConfig { queue_cleanup_grace_period_minutes?: number; queue_cleanup_allowlist?: IgnoredMessage[]; cleanup_automatic_import_failure?: boolean; + stuck_cleanup_enabled?: boolean; + stuck_cleanup_grace_period_minutes?: number; + stuck_cleanup_rules?: StuckCleanupRule[]; +} + +export type StuckCleanupAction = "remove" | "blocklist" | "blocklist_search"; + +export interface StuckCleanupRule { + message: string; + enabled: boolean; + action: StuckCleanupAction; } // Sync status and progress types diff --git a/internal/api/types.go b/internal/api/types.go index 51cee2b5b..92318d10a 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -184,6 +184,9 @@ type ArrsAPIResponse struct { CleanupAutomaticImportFailure bool `json:"cleanup_automatic_import_failure,omitempty"` QueueCleanupGracePeriodMinutes int `json:"queue_cleanup_grace_period_minutes,omitempty"` QueueCleanupAllowlist []config.IgnoredMessage `json:"queue_cleanup_allowlist,omitempty"` + StuckCleanupEnabled bool `json:"stuck_cleanup_enabled"` + StuckCleanupGracePeriodMinutes int `json:"stuck_cleanup_grace_period_minutes,omitempty"` + StuckCleanupRules []config.StuckCleanupRule `json:"stuck_cleanup_rules,omitempty"` } // ArrsInstanceAPIResponse sanitizes ArrsInstance config for API responses @@ -366,6 +369,9 @@ func ToConfigAPIResponse(cfg *config.Config, apiKey string) *ConfigAPIResponse { CleanupAutomaticImportFailure: cfg.Arrs.CleanupAutomaticImportFailure != nil && *cfg.Arrs.CleanupAutomaticImportFailure, QueueCleanupGracePeriodMinutes: cfg.Arrs.QueueCleanupGracePeriodMinutes, QueueCleanupAllowlist: cfg.Arrs.QueueCleanupAllowlist, + StuckCleanupEnabled: cfg.Arrs.StuckCleanupEnabled != nil && *cfg.Arrs.StuckCleanupEnabled, + StuckCleanupGracePeriodMinutes: cfg.Arrs.StuckCleanupGracePeriodMinutes, + StuckCleanupRules: cfg.Arrs.StuckCleanupRules, } stremioResp := StremioAPIResponse{ diff --git a/internal/arrs/clients/sportarr.go b/internal/arrs/clients/sportarr.go index 516e8dd1c..8bdab369d 100644 --- a/internal/arrs/clients/sportarr.go +++ b/internal/arrs/clients/sportarr.go @@ -229,7 +229,17 @@ func (s *Sportarr) GetQueue(ctx context.Context) ([]SportarrQueueItem, error) { // DeleteQueueItem removes a queue item, also instructing Sportarr to remove the // download from the (AltMount) download client. It does not blocklist. func (s *Sportarr) DeleteQueueItem(ctx context.Context, id int64) error { - path := fmt.Sprintf("/api/queue/%d?removeFromClient=true&blocklist=false", id) + return s.deleteQueueItem(ctx, id, false) +} + +// DeleteQueueItemBlocklist removes a queue item and blocklists the release so the +// same NZB is not grabbed again, prompting Sportarr to search for a replacement. +func (s *Sportarr) DeleteQueueItemBlocklist(ctx context.Context, id int64) error { + return s.deleteQueueItem(ctx, id, true) +} + +func (s *Sportarr) deleteQueueItem(ctx context.Context, id int64, blocklist bool) error { + path := fmt.Sprintf("/api/queue/%d?removeFromClient=true&blocklist=%t", id, blocklist) req, err := s.newRequest(ctx, http.MethodDelete, path) if err != nil { return err diff --git a/internal/arrs/service.go b/internal/arrs/service.go index 4f02521aa..28bbf03ec 100644 --- a/internal/arrs/service.go +++ b/internal/arrs/service.go @@ -206,8 +206,8 @@ func (s *Service) StopWorker(ctx context.Context) { // the queue cleanup worker when arrs.enabled or arrs.queue_cleanup_enabled flips. func (s *Service) RegisterConfigChangeHandler(ctx context.Context, configManager *config.Manager) { configManager.OnConfigChange(func(oldConfig, newConfig *config.Config) { - oldOn := worker.IsQueueCleanupEnabled(oldConfig) - newOn := worker.IsQueueCleanupEnabled(newConfig) + oldOn := worker.IsQueueCleanupEnabled(oldConfig) || worker.IsStuckCleanupEnabled(oldConfig) + newOn := worker.IsQueueCleanupEnabled(newConfig) || worker.IsStuckCleanupEnabled(newConfig) if oldOn == newOn { return } diff --git a/internal/arrs/worker/stuck_cleanup.go b/internal/arrs/worker/stuck_cleanup.go new file mode 100644 index 000000000..69b209660 --- /dev/null +++ b/internal/arrs/worker/stuck_cleanup.go @@ -0,0 +1,429 @@ +package worker + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/javi11/altmount/internal/arrs/model" + "github.com/javi11/altmount/internal/arrs/registrar" + "github.com/javi11/altmount/internal/config" + "golift.io/starr" + "golift.io/starr/sonarr" +) + +// StuckCleanupResult summarizes a stuck-import cleanup run across all instances. +type StuckCleanupResult struct { + Instances []InstanceCleanupResult `json:"instances"` + TotalBlocked int `json:"total_blocked"` +} + +// InstanceCleanupResult is the per-instance outcome of a stuck-import cleanup run. +type InstanceCleanupResult struct { + Instance string `json:"instance"` + Type string `json:"type"` + Blocked int `json:"blocked"` + Error string `json:"error,omitempty"` +} + +// stuckItem is a normalized view of an *arr queue record across all client types, +// holding only the fields the stuck-import detection needs. +type stuckItem struct { + ID int64 + Title string + TrackedDownloadStatus string + TrackedDownloadState string // empty when the *arr does not report it (e.g. Lidarr) + DownloadClient string + Messages []string +} + +// IsStuckCleanupEnabled reports whether the background stuck-import cleanup pass +// should run, based on arrs.enabled and arrs.stuck_cleanup_enabled. +func IsStuckCleanupEnabled(cfg *config.Config) bool { + if cfg.Arrs.Enabled == nil || !*cfg.Arrs.Enabled { + return false + } + return cfg.Arrs.StuckCleanupEnabled != nil && *cfg.Arrs.StuckCleanupEnabled +} + +// CleanupStuckQueue scans every enabled *arr instance for items AltMount sent that +// are stuck importing for a known reason, then removes and blocklists them so the +// release is not grabbed again and the *arr searches for a replacement. +// +// When force is false an item is only acted on after it has been continuously +// observed stuck for the configured grace period (transient errors that the *arr +// resolves on its own are left alone). When force is true the grace period is +// bypassed and everything currently matching is blocklisted immediately. +// +// The automatic periodic run is gated by IsStuckCleanupEnabled at the caller +// (the worker tick); this method itself only requires arrs to be enabled, so the +// manual trigger works regardless of the periodic toggle. +func (w *Worker) CleanupStuckQueue(ctx context.Context, force bool) (*StuckCleanupResult, error) { + cfg := w.configGetter() + result := &StuckCleanupResult{Instances: []InstanceCleanupResult{}} + + if cfg.Arrs.Enabled == nil || !*cfg.Arrs.Enabled { + return result, nil + } + + for _, instance := range w.instances.GetAllInstances() { + if instance == nil || !instance.Enabled { + continue + } + + var blocked int + var err error + switch instance.Type { + case "radarr": + blocked, err = w.cleanupStuckRadarr(ctx, instance, cfg, force) + case "sonarr": + blocked, err = w.cleanupStuckSonarr(ctx, instance, cfg, force, false) + case "whisparr": + blocked, err = w.cleanupStuckSonarr(ctx, instance, cfg, force, true) + case "lidarr": + blocked, err = w.cleanupStuckLidarr(ctx, instance, cfg, force) + case "readarr": + blocked, err = w.cleanupStuckReadarr(ctx, instance, cfg, force) + case "sportarr": + blocked, err = w.cleanupStuckSportarr(ctx, instance, cfg, force) + default: + continue + } + + res := InstanceCleanupResult{Instance: instance.Name, Type: instance.Type, Blocked: blocked} + if err != nil { + res.Error = err.Error() + slog.WarnContext(ctx, "Failed to clean up stuck imports", + "instance", instance.Name, "type", instance.Type, "error", err) + } + result.Instances = append(result.Instances, res) + result.TotalBlocked += blocked + } + + if result.TotalBlocked > 0 { + slog.InfoContext(ctx, "Stuck import cleanup acted on releases", + "count", result.TotalBlocked, "force", force) + } + return result, nil +} + +// stuckAction is a queue item selected for cleanup plus how to act on it +// (one of the config.StuckAction* values). +type stuckAction struct { + ID int64 + Action string +} + +// matchStuckRule returns the first enabled rule whose message matches any of the +// item's status messages (case-insensitive substring), or nil when none match. +func matchStuckRule(messages []string, rules []config.StuckCleanupRule) *config.StuckCleanupRule { + if len(messages) == 0 { + return nil + } + joined := strings.ToLower(strings.Join(messages, " ")) + for i := range rules { + r := &rules[i] + if !r.Enabled || r.Message == "" { + continue + } + if strings.Contains(joined, strings.ToLower(r.Message)) { + return r + } + } + return nil +} + +// stuckRuleFor returns the cleanup rule for an item, or nil if it is not stuck. +// An item must be flagged with a warning by the *arr and match an enabled rule. +func stuckRuleFor(item stuckItem, cfg *config.Config) *config.StuckCleanupRule { + if !strings.EqualFold(item.TrackedDownloadStatus, "warning") { + return nil + } + return matchStuckRule(item.Messages, cfg.Arrs.StuckCleanupRules) +} + +// selectStuckActions filters AltMount-owned queue items to those that should be +// cleaned now, carrying each item's blocklist decision. With force, all matching +// items are returned immediately. Otherwise an item must have been observed stuck +// for the configured grace period; first observations and items the *arr has since +// resolved are tracked/cleared via the shared firstSeen map. +func (w *Worker) selectStuckActions(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, items []stuckItem, force bool) []stuckAction { + var actions []stuckAction + gracePeriod := time.Duration(cfg.Arrs.StuckCleanupGracePeriodMinutes) * time.Minute + + for _, item := range items { + // Only ever touch items owned by AltMount's download client — other + // clients may reference paths AltMount cannot see (see issue #523). + if item.DownloadClient != registrar.AltmountDownloadClientName { + continue + } + + key := fmt.Sprintf("stuck|%s|%d", instance.Name, item.ID) + + rule := stuckRuleFor(item, cfg) + if rule == nil { + w.firstSeenMu.Lock() + delete(w.firstSeen, key) + w.firstSeenMu.Unlock() + continue + } + + if force || gracePeriod <= 0 { + actions = append(actions, stuckAction{ID: item.ID, Action: rule.Action}) + w.firstSeenMu.Lock() + delete(w.firstSeen, key) + w.firstSeenMu.Unlock() + continue + } + + w.firstSeenMu.Lock() + seenTime, exists := w.firstSeen[key] + if !exists { + w.firstSeen[key] = time.Now() + w.firstSeenMu.Unlock() + slog.DebugContext(ctx, "First saw stuck import, starting grace period", + "title", item.Title, "instance", instance.Name) + continue + } + w.firstSeenMu.Unlock() + + if time.Since(seenTime) < gracePeriod { + continue + } + + actions = append(actions, stuckAction{ID: item.ID, Action: rule.Action}) + w.firstSeenMu.Lock() + delete(w.firstSeen, key) + w.firstSeenMu.Unlock() + } + + return actions +} + +// starrDeleteOpts maps a stuck action to starr queue-delete options. The item is +// always removed from AltMount's download client. blocklist blocks the release; +// SkipRedownload is false only for blocklist_search so the *arr re-searches. +func starrDeleteOpts(action string) *starr.QueueDeleteOpts { + removeFromClient := true + blocklist := action == config.StuckActionBlocklist || action == config.StuckActionBlocklistSearch + search := action == config.StuckActionBlocklistSearch + return &starr.QueueDeleteOpts{ + RemoveFromClient: &removeFromClient, + BlockList: blocklist, + SkipRedownload: !search, + } +} + +// deleteStarrQueue runs the per-item starr delete (per its action) and counts +// successes, tolerating already-removed (404) items. +func (w *Worker) deleteStarrQueue(ctx context.Context, instance *model.ConfigInstance, actions []stuckAction, del func(ctx context.Context, id int64, opts *starr.QueueDeleteOpts) error) int { + cleaned := 0 + for _, a := range actions { + if err := del(ctx, a.ID, starrDeleteOpts(a.Action)); err != nil { + if strings.Contains(err.Error(), "404") { + slog.DebugContext(ctx, "Stuck queue item already removed", "instance", instance.Name, "id", a.ID) + continue + } + slog.ErrorContext(ctx, "Failed to clean stuck queue item", + "instance", instance.Name, "id", a.ID, "action", a.Action, "error", err) + continue + } + cleaned++ + } + if cleaned > 0 { + slog.InfoContext(ctx, "Cleaned stuck imports", + "instance", instance.Name, "type", instance.Type, "count", cleaned) + } + return cleaned +} + +// flattenStarrMessages collapses starr status messages (title + lines) into a flat +// slice for pattern matching. +func flattenStarrMessages(msgs []*starr.StatusMessage) []string { + out := make([]string, 0, len(msgs)) + for _, m := range msgs { + if m == nil { + continue + } + if m.Title != "" { + out = append(out, m.Title) + } + out = append(out, m.Messages...) + } + return out +} + +func (w *Worker) cleanupStuckRadarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool) (int, error) { + client, err := w.clients.GetOrCreateRadarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + return 0, fmt.Errorf("failed to get Radarr client: %w", err) + } + queue, err := client.GetQueueContext(ctx, 0, 500) + if err != nil { + return 0, fmt.Errorf("failed to get Radarr queue: %w", err) + } + + items := make([]stuckItem, 0, len(queue.Records)) + for _, q := range queue.Records { + items = append(items, stuckItem{ + ID: q.ID, + Title: q.Title, + TrackedDownloadStatus: q.TrackedDownloadStatus, + TrackedDownloadState: q.TrackedDownloadState, + DownloadClient: q.DownloadClient, + Messages: flattenStarrMessages(q.StatusMessages), + }) + } + + actions := w.selectStuckActions(ctx, instance, cfg, items, force) + return w.deleteStarrQueue(ctx, instance, actions, client.DeleteQueueContext), nil +} + +// cleanupStuckSonarr handles Sonarr and Whisparr (both use the Sonarr client). +func (w *Worker) cleanupStuckSonarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool, whisparr bool) (int, error) { + var ( + client *sonarr.Sonarr + err error + ) + if whisparr { + client, err = w.clients.GetOrCreateWhisparrClient(instance.Name, instance.URL, instance.APIKey) + } else { + client, err = w.clients.GetOrCreateSonarrClient(instance.Name, instance.URL, instance.APIKey) + } + if err != nil { + return 0, fmt.Errorf("failed to get Sonarr client: %w", err) + } + queue, err := client.GetQueueContext(ctx, 0, 500) + if err != nil { + return 0, fmt.Errorf("failed to get Sonarr queue: %w", err) + } + + items := make([]stuckItem, 0, len(queue.Records)) + for _, q := range queue.Records { + items = append(items, stuckItem{ + ID: q.ID, + Title: q.Title, + TrackedDownloadStatus: q.TrackedDownloadStatus, + TrackedDownloadState: q.TrackedDownloadState, + DownloadClient: q.DownloadClient, + Messages: flattenStarrMessages(q.StatusMessages), + }) + } + + actions := w.selectStuckActions(ctx, instance, cfg, items, force) + return w.deleteStarrQueue(ctx, instance, actions, client.DeleteQueueContext), nil +} + +func (w *Worker) cleanupStuckLidarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool) (int, error) { + client, err := w.clients.GetOrCreateLidarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + return 0, fmt.Errorf("failed to get Lidarr client: %w", err) + } + queue, err := client.GetQueueContext(ctx, 0, 500) + if err != nil { + return 0, fmt.Errorf("failed to get Lidarr queue: %w", err) + } + + items := make([]stuckItem, 0, len(queue.Records)) + for _, q := range queue.Records { + // Lidarr's queue record has no trackedDownloadState; leave it empty (it is + // not used for gating — only trackedDownloadStatus and the rule match are). + items = append(items, stuckItem{ + ID: q.ID, + Title: q.Title, + TrackedDownloadStatus: q.TrackedDownloadStatus, + DownloadClient: q.DownloadClient, + Messages: flattenStarrMessages(q.StatusMessages), + }) + } + + actions := w.selectStuckActions(ctx, instance, cfg, items, force) + return w.deleteStarrQueue(ctx, instance, actions, client.DeleteQueueContext), nil +} + +func (w *Worker) cleanupStuckReadarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool) (int, error) { + client, err := w.clients.GetOrCreateReadarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + return 0, fmt.Errorf("failed to get Readarr client: %w", err) + } + queue, err := client.GetQueueContext(ctx, 0, 500) + if err != nil { + return 0, fmt.Errorf("failed to get Readarr queue: %w", err) + } + + items := make([]stuckItem, 0, len(queue.Records)) + for _, q := range queue.Records { + items = append(items, stuckItem{ + ID: q.ID, + Title: q.Title, + TrackedDownloadStatus: q.TrackedDownloadStatus, + TrackedDownloadState: q.TrackedDownloadState, + DownloadClient: q.DownloadClient, + Messages: flattenStarrMessages(q.StatusMessages), + }) + } + + actions := w.selectStuckActions(ctx, instance, cfg, items, force) + return w.deleteStarrQueue(ctx, instance, actions, client.DeleteQueueContext), nil +} + +// cleanupStuckSportarr mirrors the starr path but uses Sportarr's native client, +// which is not starr-compatible. +func (w *Worker) cleanupStuckSportarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool) (int, error) { + client, err := w.clients.GetOrCreateSportarrClient(instance.Name, instance.URL, instance.APIKey) + if err != nil { + return 0, fmt.Errorf("failed to get Sportarr client: %w", err) + } + queue, err := client.GetQueue(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get Sportarr queue: %w", err) + } + + items := make([]stuckItem, 0, len(queue)) + for _, q := range queue { + var messages []string + for _, m := range q.StatusMessages { + if m.Title != "" { + messages = append(messages, m.Title) + } + messages = append(messages, m.Messages...) + } + items = append(items, stuckItem{ + ID: q.ID, + Title: q.Title, + TrackedDownloadStatus: q.TrackedDownloadStatus, + TrackedDownloadState: q.TrackedDownloadState, + DownloadClient: q.DownloadClient.Name, + Messages: messages, + }) + } + + actions := w.selectStuckActions(ctx, instance, cfg, items, force) + cleaned := 0 + for _, a := range actions { + var err error + // Sportarr's native API has no skipRedownload flag, so blocklist and + // blocklist_search both map to a blocklisting delete. + if a.Action == config.StuckActionBlocklist || a.Action == config.StuckActionBlocklistSearch { + err = client.DeleteQueueItemBlocklist(ctx, a.ID) + } else { + err = client.DeleteQueueItem(ctx, a.ID) + } + if err != nil { + if strings.Contains(err.Error(), "404") { + slog.DebugContext(ctx, "Stuck queue item already removed from Sportarr", "instance", instance.Name, "id", a.ID) + continue + } + slog.ErrorContext(ctx, "Failed to clean stuck Sportarr queue item", + "instance", instance.Name, "id", a.ID, "action", a.Action, "error", err) + continue + } + cleaned++ + } + if cleaned > 0 { + slog.InfoContext(ctx, "Cleaned stuck Sportarr imports", "instance", instance.Name, "count", cleaned) + } + return cleaned, nil +} diff --git a/internal/arrs/worker/worker.go b/internal/arrs/worker/worker.go index 2c20543c6..3c7d59181 100644 --- a/internal/arrs/worker/worker.go +++ b/internal/arrs/worker/worker.go @@ -65,8 +65,9 @@ func (w *Worker) Start(ctx context.Context) error { return nil } - // Queue cleanup is enabled by default (when nil or true) - if cfg.Arrs.QueueCleanupEnabled != nil && !*cfg.Arrs.QueueCleanupEnabled { + // The worker runs when either the ghost/import cleanup or the stuck-import + // blocklist cleanup is enabled. Ghost cleanup is on by default (nil or true). + if !IsQueueCleanupEnabled(cfg) && !IsStuckCleanupEnabled(cfg) { slog.InfoContext(ctx, "ARR queue cleanup disabled") return nil } @@ -137,6 +138,13 @@ func (w *Worker) safeCleanup() { if err := w.CleanupQueue(w.workerCtx); err != nil { slog.Error("Queue cleanup failed", "error", err) } + // Stuck-import cleanup runs on the same tick when enabled. force=false so it + // observes items over time and only blocklists those stuck past the grace period. + if IsStuckCleanupEnabled(w.configGetter()) { + if _, err := w.CleanupStuckQueue(w.workerCtx, false); err != nil { + slog.Error("Stuck import cleanup failed", "error", err) + } + } } // IsQueueCleanupEnabled reports whether the queue cleanup feature should be diff --git a/internal/config/manager.go b/internal/config/manager.go index 9898b5eb2..ee63400a2 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -457,6 +457,37 @@ type ArrsConfig struct { CleanupAutomaticImportFailure *bool `yaml:"cleanup_automatic_import_failure" mapstructure:"cleanup_automatic_import_failure" json:"cleanup_automatic_import_failure,omitempty"` QueueCleanupGracePeriodMinutes int `yaml:"queue_cleanup_grace_period_minutes" mapstructure:"queue_cleanup_grace_period_minutes" json:"queue_cleanup_grace_period_minutes,omitempty"` QueueCleanupAllowlist []IgnoredMessage `yaml:"queue_cleanup_allowlist" mapstructure:"queue_cleanup_allowlist" json:"queue_cleanup_allowlist,omitempty"` + + // Stuck import cleanup. When an item AltMount sent to an *arr gets stuck + // importing for a known reason (matched by StuckCleanupRules), this removes it + // from the queue once it has been stuck for the grace period. Rules flagged + // blocklist also block the release so the same NZB is not grabbed again and the + // *arr searches for a replacement; non-blocklist rules just clear the queue. + // Only items owned by AltMount's download client are touched (see issue #523). + StuckCleanupEnabled *bool `yaml:"stuck_cleanup_enabled" mapstructure:"stuck_cleanup_enabled" json:"stuck_cleanup_enabled,omitempty"` + StuckCleanupGracePeriodMinutes int `yaml:"stuck_cleanup_grace_period_minutes" mapstructure:"stuck_cleanup_grace_period_minutes" json:"stuck_cleanup_grace_period_minutes,omitempty"` + StuckCleanupRules []StuckCleanupRule `yaml:"stuck_cleanup_rules" mapstructure:"stuck_cleanup_rules" json:"stuck_cleanup_rules,omitempty"` +} + +// Stuck cleanup actions decide what happens to a matched stuck import. +const ( + // StuckActionRemove removes the item from the queue only (no blocklist, no + // re-search) — for transient/environmental errors or already-satisfied files. + StuckActionRemove = "remove" + // StuckActionBlocklist removes the item and blocklists the release so the same + // NZB is not grabbed again, but does NOT trigger a new search. + StuckActionBlocklist = "blocklist" + // StuckActionBlocklistSearch blocklists the release and triggers the *arr to + // search for a replacement. + StuckActionBlocklistSearch = "blocklist_search" +) + +// StuckCleanupRule matches a stuck-import status message and decides the action +// (one of StuckActionRemove, StuckActionBlocklist, StuckActionBlocklistSearch). +type StuckCleanupRule struct { + Message string `yaml:"message" mapstructure:"message" json:"message"` + Enabled bool `yaml:"enabled" mapstructure:"enabled" json:"enabled"` + Action string `yaml:"action" mapstructure:"action" json:"action"` } // ArrsInstanceConfig represents a single arrs instance configuration @@ -1522,6 +1553,41 @@ func DefaultConfig(configDir ...string) *Config { {Message: "Unexpected error processing file", Enabled: true}, {Message: "Download doesn't contain intermediate path", Enabled: true}, }, + StuckCleanupGracePeriodMinutes: 5, // Default to 5 minutes stuck before acting + // Rule table modeled on wArrden's queue cleanup. Action decides what to do: + // blocklist_search (bad release → block + re-search), blocklist (block but + // don't search), or remove (just clear the queue: transient/environmental + // errors or files that are already satisfied). + StuckCleanupRules: []StuckCleanupRule{ + // Bad release — blocklist and search for a replacement. + {Message: "Sample", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "Unable to determine if file is a sample", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "is not a valid video file", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "No files found are eligible", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "No audio tracks detected", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "Found archive file", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "Unable to parse file", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "Unexpected error processing file", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "unsupported extension", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "potentially dangerous file", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "Found executable file", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "was not found in the grabbed release", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "Invalid season or episode", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "One or more episodes expected in this release were not imported or missing", Enabled: true, Action: StuckActionBlocklistSearch}, + // Already satisfied — remove from queue only (don't re-search). + {Message: "Not a Custom Format upgrade", Enabled: true, Action: StuckActionRemove}, + {Message: "Not an upgrade for existing", Enabled: true, Action: StuckActionRemove}, + {Message: "Not a quality revision upgrade", Enabled: true, Action: StuckActionRemove}, + {Message: "Movie file already imported", Enabled: true, Action: StuckActionRemove}, + {Message: "Episode file already imported", Enabled: true, Action: StuckActionRemove}, + {Message: "Album already imported", Enabled: true, Action: StuckActionRemove}, + // Transient/environmental — disabled by default (enable if desired). + {Message: "Not enough free space", Enabled: false, Action: StuckActionRemove}, + {Message: "File is still being unpacked", Enabled: false, Action: StuckActionRemove}, + {Message: "Locked file, try again later", Enabled: false, Action: StuckActionRemove}, + {Message: "is reporting an error", Enabled: false, Action: StuckActionRemove}, + {Message: "Import failed, path does not exist", Enabled: false, Action: StuckActionRemove}, + }, }, Fuse: FuseConfig{ Enabled: &fuseEnabled, From 81cce07123ed7a9d7065e18a3a019bf24676043d Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:13:24 +0530 Subject: [PATCH 02/11] feat(health): remove indexer HUD comparison chart --- .../IndexerHealth/IndexerHealth.tsx | 18 --- .../IndexerHealth/IndexerHealthChart.tsx | 119 ------------------ 2 files changed, 137 deletions(-) delete mode 100644 frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthChart.tsx diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealth.tsx b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealth.tsx index d7ab680f6..92c720d2a 100644 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealth.tsx +++ b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealth.tsx @@ -4,7 +4,6 @@ import { useConfirm } from "../../../../contexts/ModalContext"; import { useToast } from "../../../../contexts/ToastContext"; import { useCleanupIndexerStats, useIndexerStats } from "../../../../hooks/useApi"; import { IndexerHealthCard } from "./IndexerHealthCard"; -import { IndexerHealthChart } from "./IndexerHealthChart"; import { IndexerHealthFilters } from "./IndexerHealthFilters"; import { IndexerHealthSummary } from "./IndexerHealthSummary"; import { PruneStatsModal } from "./PruneStatsModal"; @@ -16,7 +15,6 @@ export function IndexerHealth() { const { showToast } = useToast(); const { confirmAction } = useConfirm(); - const [showChart, setShowChart] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState< "all" | "excellent" | "good" | "moderate" | "operational" @@ -196,20 +194,6 @@ export function IndexerHealth() {

-
- {hasStats && showChart && } - {summary && } {hasStats && ( diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthChart.tsx b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthChart.tsx deleted file mode 100644 index 37dbf1654..000000000 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthChart.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { BarChart2 } from "lucide-react"; -import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; -import type { IndexerStat } from "./types"; - -const ChartTooltip = ({ - active, - payload, -}: { - active?: boolean; - payload?: { value: number; payload: { name: string } }[]; -}) => { - if (!active || !payload || payload.length === 0) return null; - const data = payload[0]; - const val = data.value; - const name = data.payload.name; - - const isExcellent = val >= 90; - const isGood = val >= 75 && val < 90; - const isPoor = val >= 50 && val < 75; - const statusText = isExcellent - ? "Excellent" - : isGood - ? "Good" - : isPoor - ? "Moderate" - : "Operational"; - const badgeColor = isExcellent - ? "bg-teal-500/10 text-teal-400 border-teal-500/20" - : isGood - ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" - : isPoor - ? "bg-amber-500/10 text-amber-500 border-amber-500/20" - : "bg-blue-500/10 text-blue-400 border-blue-500/20"; - - return ( -
-

{name}

-
- - {statusText} - - {val.toFixed(1)}% -
-
- ); -}; - -interface IndexerHealthChartProps { - sorted: IndexerStat[]; -} - -export function IndexerHealthChart({ sorted }: IndexerHealthChartProps) { - return ( -
-
-
-

-

-

- Comparative efficiency rating across active indexers (%) -

-
-
-
- - ({ - name: s.indexer, - health: Math.round(s.success_rate * 10) / 10, - }))} - margin={{ top: 10, right: 10, left: -20, bottom: 5 }} - > - - `${v}%`} - /> - } - cursor={{ fill: "rgba(255, 255, 255, 0.05)", radius: 4 }} - /> - - {sorted.map((entry, index) => { - const isExcellent = entry.success_rate >= 90; - const isGood = entry.success_rate >= 75 && entry.success_rate < 90; - const isPoor = entry.success_rate >= 50 && entry.success_rate < 75; - const color = isExcellent - ? "#0d9488" - : isGood - ? "#059669" - : isPoor - ? "#d97706" - : "#e11d48"; - return ; - })} - - - -
-
- ); -} From d991eec049f1cd6a6247c94d4c39ee196305386a Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:22:27 +0530 Subject: [PATCH 03/11] =?UTF-8?q?feat(health):=20tidy=20indexer=20health?= =?UTF-8?q?=20UI=20=E2=80=94=20drop=20best/worst=20chips,=20persist=20sort?= =?UTF-8?q?,=20theme-consistent=20colors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove the best/worst performer chips (Highest efficiency rating / Needs telemetry inspection) - persist the chosen sort (key + direction) to localStorage so it survives refresh - replace hardcoded teal/emerald/amber/rose/slate + rgba with daisyUI theme tokens (success/warning/error/primary) so colors follow the active theme - convert filter/sort role=group divs to fieldset (a11y) --- .../IndexerHealth/IndexerHealth.tsx | 47 ++++++++--- .../IndexerHealth/IndexerHealthCard.tsx | 65 +++++++--------- .../IndexerHealth/IndexerHealthFilters.tsx | 35 +++------ .../IndexerHealth/IndexerHealthSummary.tsx | 78 +++++-------------- .../IndexerHealth/PruneStatsModal.tsx | 4 +- .../components/IndexerHealth/types.ts | 2 - 6 files changed, 98 insertions(+), 133 deletions(-) diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealth.tsx b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealth.tsx index 92c720d2a..3d446ce0a 100644 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealth.tsx +++ b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealth.tsx @@ -1,5 +1,5 @@ import { AlertTriangle, BarChart3, Radio, RefreshCw, Trash2 } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useConfirm } from "../../../../contexts/ModalContext"; import { useToast } from "../../../../contexts/ToastContext"; import { useCleanupIndexerStats, useIndexerStats } from "../../../../hooks/useApi"; @@ -9,6 +9,25 @@ import { IndexerHealthSummary } from "./IndexerHealthSummary"; import { PruneStatsModal } from "./PruneStatsModal"; import type { IndexerSummary, SortKey } from "./types"; +const SORT_STORAGE_KEY = "altmount.indexerHealth.sort"; + +// Persisted sort preference so the chosen order survives a page refresh instead +// of resetting to health-descending. +function loadSortPref(): { key: SortKey; asc: boolean } { + try { + const raw = localStorage.getItem(SORT_STORAGE_KEY); + if (raw) { + const p = JSON.parse(raw) as { key?: unknown; asc?: unknown }; + const key: SortKey = p.key === "total" || p.key === "name" ? p.key : "health"; + const asc = typeof p.asc === "boolean" ? p.asc : false; + return { key, asc }; + } + } catch { + // ignore malformed/unavailable storage + } + return { key: "health", asc: false }; +} + export function IndexerHealth() { const { data: stats, isLoading, error, refetch } = useIndexerStats(); const cleanupStats = useCleanupIndexerStats(); @@ -20,8 +39,17 @@ export function IndexerHealth() { "all" | "excellent" | "good" | "moderate" | "operational" >("all"); const [showPruneModal, setShowPruneModal] = useState(false); - const [sortKey, setSortKey] = useState("health"); - const [sortAsc, setSortAsc] = useState(false); + const [sortKey, setSortKey] = useState(() => loadSortPref().key); + const [sortAsc, setSortAsc] = useState(() => loadSortPref().asc); + + // Persist the sort preference whenever it changes. + useEffect(() => { + try { + localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify({ key: sortKey, asc: sortAsc })); + } catch { + // ignore storage write failures + } + }, [sortKey, sortAsc]); const handlePrune = async (hours: number) => { try { @@ -111,9 +139,7 @@ export function IndexerHealth() { const totalSuccess = stats.reduce((s, x) => s + x.success_count, 0); const totalFailed = stats.reduce((s, x) => s + x.failed_count, 0); const overallRate = totalImports > 0 ? (totalSuccess / totalImports) * 100 : 0; - const best = [...stats].sort((a, b) => b.success_rate - a.success_rate)[0]; - const worst = [...stats].sort((a, b) => a.success_rate - b.success_rate)[0]; - return { totalImports, totalSuccess, totalFailed, overallRate, best, worst }; + return { totalImports, totalSuccess, totalFailed, overallRate }; }, [stats]); if (isLoading) { @@ -183,10 +209,7 @@ export function IndexerHealth() {

-

@@ -205,7 +228,7 @@ export function IndexerHealth() {

+
{/* Sort Toolbar */} @@ -90,9 +78,8 @@ export function IndexerHealthFilters({ Sort by -
{(["health", "total", "name"] as SortKey[]).map((key) => ( @@ -102,7 +89,7 @@ export function IndexerHealthFilters({ onClick={() => onSort(key)} className={`btn btn-xs join-item border-none font-bold capitalize tracking-wide transition-all duration-200 ${ sortKey === key - ? "btn-primary shadow-[0_0_8px_rgba(59,130,246,0.25)]" + ? "btn-primary" : "btn-ghost text-base-content/60 hover:bg-base-content/5 hover:text-base-content" }`} aria-label={`Sort by ${key === "health" ? "Health" : key === "total" ? "Volume" : "Name"}`} @@ -113,7 +100,7 @@ export function IndexerHealthFilters({ )} ))} -
+ {filteredCount} Indexer{filteredCount !== 1 ? "s" : ""} active diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthSummary.tsx b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthSummary.tsx index 2c2b6d6a8..d27731fa7 100644 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthSummary.tsx +++ b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthSummary.tsx @@ -1,4 +1,4 @@ -import { Activity, BarChart2, CheckCircle2, TrendingDown, TrendingUp, XCircle } from "lucide-react"; +import { Activity, BarChart2, CheckCircle2, XCircle } from "lucide-react"; import type { IndexerStat, IndexerSummary } from "./types"; interface IndexerHealthSummaryProps { @@ -7,118 +7,80 @@ interface IndexerHealthSummaryProps { } export function IndexerHealthSummary({ stats, summary }: IndexerHealthSummaryProps) { + const overallColor = + summary.overallRate >= 85 + ? "text-success" + : summary.overallRate >= 60 + ? "text-warning" + : "text-error"; + return (
{/* Total Indexers Card */} -
+
Tracked Indexers -
+
{stats.length}
Active Integrations
-
+
{/* Overall Health Card */} -
- {summary.overallRate >= 85 && ( -
- )} +
System Health -
= 85 - ? "text-teal-600 dark:text-teal-400" - : summary.overallRate >= 60 - ? "text-amber-600 dark:text-amber-500" - : "text-rose-600 dark:text-rose-400" - }`} - > +
{summary.overallRate.toFixed(1)}%
Average success rate
-
= 85 - ? "text-teal-600 shadow-[0_0_12px_rgba(13,148,136,0.3)] dark:text-teal-400" - : summary.overallRate >= 60 - ? "text-amber-600 shadow-[0_0_12px_rgba(245,158,11,0.3)] dark:text-amber-500" - : "text-rose-600 shadow-[0_0_12px_rgba(225,29,72,0.3)] dark:text-rose-400" - }`} - > +
{/* Successful Imports Card */} -
+
Successful Imports -
+
{summary.totalSuccess.toLocaleString()}
Imports completed
-
+
{/* Failed Imports Card */} -
+
Failed Imports -
+
{summary.totalFailed.toLocaleString()}
Verification failures
-
+
- - {/* Best / Worst performer chips */} - {stats.length > 1 && ( - <> -
-
-
-
- - )}
); } diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/PruneStatsModal.tsx b/frontend/src/pages/HealthPage/components/IndexerHealth/PruneStatsModal.tsx index c49fec84d..ba3ebb20e 100644 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/PruneStatsModal.tsx +++ b/frontend/src/pages/HealthPage/components/IndexerHealth/PruneStatsModal.tsx @@ -33,7 +33,7 @@ export function PruneStatsModal({ isPending, onClose, onPrune }: PruneStatsModal id="prune-modal-title" className="flex items-center gap-2 font-bold text-base-content text-xl" > -
-
- )} - - {!isReadOnly && ( -
- -
- )} - - )} -
- - {/* RC Configuration Section */} -
-
- -

- Remote Control (RC) -

-
-
- -
- Enable RC Connection - -
- - {(formData.rc_enabled || mountFormData.mount_enabled) && ( - <> -
-
- RC URL - handleInputChange("rc_url", e.target.value)} - placeholder={ - mountFormData.mount_enabled - ? "Internal server (managed by mount)" - : "http://localhost:5572" - } - /> -
- -
- RC Port - - handleInputChange("rc_port", Number.parseInt(e.target.value, 10) || 5572) - } - /> -
-
- -
-
- RC Username - handleInputChange("rc_user", e.target.value)} - /> -
- -
- RC Password -
- handleInputChange("rc_pass", e.target.value)} - placeholder={config.rclone.rc_pass_set ? "********" : "admin"} - /> - -
-
-
- - {/* Custom RC Options */} -
-
Custom RC Options
- handleInputChange("rc_options", val)} - keyPlaceholder="Option (e.g. rc-web-gui)" - valuePlaceholder="Value (e.g. true)" - /> -
- - {!isReadOnly && !mountFormData.mount_enabled && ( -
- - -
- )} - - )} -
-
- - {/* Test Result Alert */} - {testResult && ( -
- {testResult.message} -
- )} -
- ); -} diff --git a/frontend/src/components/queue/ManualScanSection.tsx b/frontend/src/components/queue/ManualScanSection.tsx deleted file mode 100644 index 5f1dfe9b5..000000000 --- a/frontend/src/components/queue/ManualScanSection.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { AlertCircle, CheckCircle2, FolderOpen, Play, Square } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useCancelScan, useScanStatus, useStartManualScan } from "../../hooks/useApi"; -import { ScanStatus } from "../../types/api"; -import { ErrorAlert } from "../ui/ErrorAlert"; - -export function ManualScanSection() { - const [scanPath, setScanPath] = useState(""); - const [validationError, setValidationError] = useState(""); - - // Auto-refresh scan status every 2 seconds when scanning - const { data: scanStatus } = useScanStatus(2000); - const startScan = useStartManualScan(); - const cancelScan = useCancelScan(); - - const isScanning = scanStatus?.status === ScanStatus.SCANNING; - const isCanceling = scanStatus?.status === ScanStatus.CANCELING; - const isIdle = scanStatus?.status === ScanStatus.IDLE || !scanStatus?.status; - - // Clear validation error when path changes - useEffect(() => { - if (validationError && scanPath) { - setValidationError(""); - } - }, [scanPath, validationError]); - - const validatePath = (path: string): boolean => { - if (!path.trim()) { - setValidationError("Path is required"); - return false; - } - - if (!path.startsWith("/")) { - setValidationError("Path must be absolute (start with /)"); - return false; - } - - setValidationError(""); - return true; - }; - - const handleStartScan = async () => { - if (!validatePath(scanPath)) { - return; - } - - try { - await startScan.mutateAsync(scanPath); - } catch (error) { - console.error("Failed to start scan:", error); - } - }; - - const handleCancelScan = async () => { - try { - await cancelScan.mutateAsync(); - } catch (error) { - console.error("Failed to cancel scan:", error); - } - }; - - const getProgressPercentage = (): number => { - if (!scanStatus || scanStatus.files_found === 0) return 0; - // Simple progress calculation based on files found vs files added - // This is approximate since we don't know the total beforehand - return Math.min((scanStatus.files_added / scanStatus.files_found) * 100, 100); - }; - - const getStatusIcon = () => { - if (isScanning) return ; - if (isCanceling) return ; - if (scanStatus?.last_error) return ; - return ; - }; - - const getStatusText = () => { - if (isCanceling) return "Canceling..."; - if (isScanning) return "Scanning"; - if (scanStatus?.last_error) return "Error"; - return "Idle"; - }; - - return ( -
-
-
- -

Manual Directory Scan

-
- - {/* Path Input and Controls */} -
-
- Directory Path - setScanPath(e.target.value)} - disabled={isScanning || isCanceling} - /> - {validationError &&

{validationError}

} -
- -
- {isIdle && ( - - )} - - {(isScanning || isCanceling) && ( - - )} -
-
- - {/* Status Display */} -
-
-
- {getStatusIcon()} - Status: {getStatusText()} -
- -
- Files Found: {scanStatus?.files_found || 0} - Files Added: {scanStatus?.files_added || 0} -
-
- - {/* Progress Bar */} - {isScanning && ( -
-
- Progress - {Math.round(getProgressPercentage())}% -
-
-
-
-
- )} - - {/* Current File */} - {isScanning && scanStatus?.current_file && ( -
- Current: - - {scanStatus.current_file.length > 60 - ? `...${scanStatus.current_file.slice(-60)}` - : scanStatus.current_file} - -
- )} - - {/* Scan Path */} - {scanStatus?.path && scanStatus.path !== scanPath && ( -
- Scanning: - {scanStatus.path} -
- )} - - {/* Error Display */} - {scanStatus?.last_error && ( -
- scanStatus?.path && handleStartScan()} - /> -
- )} - - {/* API Error Display */} - {(startScan.error || cancelScan.error) && ( -
- { - startScan.reset(); - cancelScan.reset(); - }} - /> -
- )} -
-
-
- ); -} diff --git a/frontend/src/components/system/ActiveStreamsCard.tsx b/frontend/src/components/system/ActiveStreamsCard.tsx deleted file mode 100644 index d9bc37b85..000000000 --- a/frontend/src/components/system/ActiveStreamsCard.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { Activity, FileVideo, Globe, MonitorPlay, Network, User } from "lucide-react"; -import { useActiveStreams } from "../../hooks/useApi"; -import { formatBytes, formatDuration, truncateText } from "../../lib/utils"; -import { LoadingSpinner } from "../ui/LoadingSpinner"; - -export function ActiveStreamsCard() { - const { data: allStreams, isLoading, error } = useActiveStreams(); - - // Filter to show only active streaming sessions (WebDAV or FUSE) - const streams = allStreams?.filter( - (s) => (s.source === "WebDAV" || s.source === "FUSE") && s.status === "Streaming", - ); - - if (error) { - return ( -
- - Failed to load active streams -
- ); - } - - if (isLoading) { - return ( -
-
- -
-
- ); - } - - return ( -
-
-

- - Active Streams - {streams && streams.length > 0 && ( -
{streams.length}
- )} -

- - {!streams || streams.length === 0 ? ( -
- -

No active streams

-
- ) : ( -
- {streams.map((stream) => { - const position = - stream.current_offset > 0 ? stream.current_offset : stream.bytes_sent; - const progress = - stream.total_size > 0 ? Math.round((position / stream.total_size) * 100) : 0; - - const bufferedProgress = - stream.total_size > 0 - ? Math.round((stream.buffered_offset / stream.total_size) * 100) - : 0; - - return ( -
-
-
- -
-
-
- {truncateText(stream.file_path.split("/").pop() || "", 40)} -
- - {/* User / Client Info */} -
- {(stream.user_name || stream.client_ip) && ( -
- {stream.user_name ? ( - - ) : ( - - )} - - {stream.user_name || stream.client_ip} - -
- )} - - {stream.user_agent && ( -
- - {stream.user_agent.split("/")[0]} - -
- )} - - {stream.total_connections > 1 && ( -
- - {stream.total_connections} -
- )} -
- -
- {stream.bytes_per_second > 0 ? ( - STREAMING - ) : ( - IDLE - )} - - - {formatBytes(stream.total_size)} - -
-
-
- -
-
-
- {progress}% - - - DL: {formatBytes(stream.bytes_downloaded)} - -
-
- {/* Speeds */} -
- {/* Download (Input) Speed */} -
- IN: - - {formatBytes(stream.download_speed)}/s - - {stream.download_speed > 0 && stream.download_speed < 1024 * 1024 && ( -
- SLOW -
- )} -
- - | - - {/* Playback (Output) Speed */} -
- OUT: - - {formatBytes(stream.bytes_per_second)}/s - -
-
- - {/* ETA */} - {stream.eta > 0 && ( - - ETA: {formatDuration(stream.eta)} - - )} -
-
- - {/* Custom progress bar with buffer */} -
- {/* Buffer Bar */} - {bufferedProgress > progress && ( -
- )} - {/* Playback Progress Bar */} -
0 ? "bg-primary" : "bg-base-content/20" - }`} - style={{ width: `${progress}%` }} - /> -
- -
- Avg: {formatBytes(stream.speed_avg)}/s -
-
-
- ); - })} -
- )} -
-
- ); -} diff --git a/frontend/src/components/system/RecentCompletions.tsx b/frontend/src/components/system/RecentCompletions.tsx deleted file mode 100644 index 83ddb0c42..000000000 --- a/frontend/src/components/system/RecentCompletions.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { CheckCircle2, History } from "lucide-react"; -import { useImportHistory } from "../../hooks/useApi"; -import { formatRelativeTime } from "../../lib/utils"; -import { LoadingSpinner } from "../ui/LoadingSpinner"; - -export function RecentCompletions() { - // Use persistent history instead of transient queue - const { data: history, isLoading } = useImportHistory(10, 10000); - - if (isLoading) return ; - if (!history || history.length === 0) return null; - - return ( -
-
-

- - Recent Successes -

-
- {history.map((item) => ( -
-
- - ${item.file_name}`}> - {item.file_name} - -
- - {formatRelativeTime(item.completed_at)} - -
- ))} -
-
-
- ); -} diff --git a/frontend/src/components/ui/BytesDisplay.tsx b/frontend/src/components/ui/BytesDisplay.tsx index de94dbc16..cb1e8a1d7 100644 --- a/frontend/src/components/ui/BytesDisplay.tsx +++ b/frontend/src/components/ui/BytesDisplay.tsx @@ -49,6 +49,3 @@ export function BytesDisplay({ bytes, mode = "inline" }: BytesDisplayProps) { return {humanReadable}; } } - -// Export the utility functions for use in other components -export { formatBytes, formatNumber }; diff --git a/frontend/src/components/ui/ErrorAlert.tsx b/frontend/src/components/ui/ErrorAlert.tsx index 1cc5f5a69..78bd83809 100644 --- a/frontend/src/components/ui/ErrorAlert.tsx +++ b/frontend/src/components/ui/ErrorAlert.tsx @@ -25,25 +25,3 @@ export function ErrorAlert({ error, onRetry, className }: ErrorAlertProps) {
); } - -export function ErrorCard({ error, onRetry }: ErrorAlertProps) { - return ( -
-
-

- - Error -

-

{error.message}

- {onRetry && ( -
- -
- )} -
-
- ); -} diff --git a/frontend/src/components/ui/KeyValueEditor.tsx b/frontend/src/components/ui/KeyValueEditor.tsx deleted file mode 100644 index 82dcc8b31..000000000 --- a/frontend/src/components/ui/KeyValueEditor.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Plus, Trash2 } from "lucide-react"; -import { useState } from "react"; - -interface KeyValueEditorProps { - value: Record; - onChange: (value: Record) => void; - keyPlaceholder?: string; - valuePlaceholder?: string; - disabled?: boolean; -} - -export function KeyValueEditor({ - value, - onChange, - keyPlaceholder = "Key", - valuePlaceholder = "Value", - disabled = false, -}: KeyValueEditorProps) { - const [newKey, setNewKey] = useState(""); - const [newValue, setNewValue] = useState(""); - - const handleAdd = () => { - if (!newKey.trim()) return; - const updated = { ...value, [newKey.trim()]: newValue.trim() }; - onChange(updated); - setNewKey(""); - setNewValue(""); - }; - - const handleRemove = (key: string) => { - const updated = { ...value }; - delete updated[key]; - onChange(updated); - }; - - const handleValueChange = (key: string, val: string) => { - const updated = { ...value, [key]: val }; - onChange(updated); - }; - - return ( -
-
- {Object.entries(value).map(([key, val]) => ( -
- - handleValueChange(key, e.target.value)} - /> - {!disabled && ( - - )} -
- ))} -
- - {!disabled && ( -
- setNewKey(e.target.value)} - /> - setNewValue(e.target.value)} - /> - -
- )} -
- ); -} diff --git a/frontend/src/components/ui/LoadingSpinner.tsx b/frontend/src/components/ui/LoadingSpinner.tsx index fa9637f3f..b4186197c 100644 --- a/frontend/src/components/ui/LoadingSpinner.tsx +++ b/frontend/src/components/ui/LoadingSpinner.tsx @@ -16,18 +16,6 @@ export function LoadingSpinner({ size = "md", className }: LoadingSpinnerProps) return ; } -export function LoadingCard({ children }: { children?: React.ReactNode }) { - return ( -
-
- -

Loading...

- {children} -
-
- ); -} - export function LoadingTable({ columns }: { columns: number }) { return (
diff --git a/frontend/src/contexts/ModalContext.tsx b/frontend/src/contexts/ModalContext.tsx index ec088a4b1..05fdc82d1 100644 --- a/frontend/src/contexts/ModalContext.tsx +++ b/frontend/src/contexts/ModalContext.tsx @@ -105,7 +105,7 @@ export function ModalProvider({ children }: ModalProviderProps) { ); } -export function useModal() { +function useModal() { const context = useContext(ModalContext); if (context === undefined) { throw new Error("useModal must be used within a ModalProvider"); diff --git a/frontend/src/hooks/useConfig.ts b/frontend/src/hooks/useConfig.ts index 5ca15ed7f..209d3e826 100644 --- a/frontend/src/hooks/useConfig.ts +++ b/frontend/src/hooks/useConfig.ts @@ -19,22 +19,6 @@ export function useConfig() { }); } -// Hook to update entire configuration -export function useUpdateConfig() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (config: ConfigUpdateRequest) => apiClient.updateConfig(config), - onSuccess: (data) => { - // Update the cache with new configuration - queryClient.setQueryData(configKeys.current(), data); - }, - onError: (error) => { - console.error("Failed to update configuration:", error); - }, - }); -} - // Hook to update specific configuration section export function useUpdateConfigSection() { const queryClient = useQueryClient(); diff --git a/frontend/src/index.css b/frontend/src/index.css index 03f238a2b..6cd9cc044 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -100,11 +100,11 @@ fieldset { width: 60px; height: 120px; border-radius: 30px; - background: rgba(0, 0, 0, 0.35); - border: 2px solid rgba(255, 255, 255, 0.12); + background: color-mix(in oklch, var(--color-base-300) 55%, transparent); + border: 2px solid color-mix(in oklch, var(--color-base-content) 12%, transparent); box-shadow: - inset 0 0 16px rgba(0, 0, 0, 0.6), - 0 8px 32px rgba(0, 0, 0, 0.4); + inset 0 0 16px rgba(0, 0, 0, 0.4), + 0 8px 32px rgba(0, 0, 0, 0.25); overflow: hidden; } @@ -113,31 +113,47 @@ fieldset { bottom: 0; left: 0; right: 0; - background: linear-gradient(180deg, rgba(16, 185, 129, 0.8) 0%, rgba(4, 120, 87, 0.95) 100%); + background: linear-gradient( + 180deg, + color-mix(in oklch, var(--color-success) 80%, transparent) 0%, + var(--color-success) 100% + ); box-shadow: - 0 0 20px rgba(16, 185, 129, 0.4), + 0 0 20px color-mix(in oklch, var(--color-success) 40%, transparent), inset 0 8px 12px rgba(255, 255, 255, 0.25); transition: height 1.2s cubic-bezier(0.4, 0, 0.2, 1); } .vial-liquid.excellent { - background: linear-gradient(180deg, rgba(20, 184, 166, 0.8) 0%, rgba(15, 118, 110, 0.95) 100%); + background: linear-gradient( + 180deg, + color-mix(in oklch, var(--color-success) 80%, transparent) 0%, + var(--color-success) 100% + ); box-shadow: - 0 0 20px rgba(20, 184, 166, 0.4), + 0 0 20px color-mix(in oklch, var(--color-success) 40%, transparent), inset 0 8px 12px rgba(255, 255, 255, 0.25); } .vial-liquid.warning { - background: linear-gradient(180deg, rgba(245, 158, 11, 0.8) 0%, rgba(180, 83, 9, 0.95) 100%); + background: linear-gradient( + 180deg, + color-mix(in oklch, var(--color-warning) 80%, transparent) 0%, + var(--color-warning) 100% + ); box-shadow: - 0 0 20px rgba(245, 158, 11, 0.4), + 0 0 20px color-mix(in oklch, var(--color-warning) 40%, transparent), inset 0 8px 12px rgba(255, 255, 255, 0.25); } .vial-liquid.error { - background: linear-gradient(180deg, rgba(239, 68, 68, 0.85) 0%, rgba(185, 28, 28, 0.95) 100%); + background: linear-gradient( + 180deg, + color-mix(in oklch, var(--color-error) 85%, transparent) 0%, + var(--color-error) 100% + ); box-shadow: - 0 0 20px rgba(239, 68, 68, 0.45), + 0 0 20px color-mix(in oklch, var(--color-error) 45%, transparent), inset 0 8px 12px rgba(255, 255, 255, 0.25); } @@ -148,7 +164,7 @@ fieldset { width: 140px; height: 140px; border-radius: 44%; - background: rgba(15, 17, 23, 0.95); + background: var(--color-base-300); animation: liquid-wave 8s linear infinite; transform-origin: 50% 50%; } diff --git a/frontend/src/pages/ConfigurationPage.tsx b/frontend/src/pages/ConfigurationPage.tsx index 294903a5c..aea576fd8 100644 --- a/frontend/src/pages/ConfigurationPage.tsx +++ b/frontend/src/pages/ConfigurationPage.tsx @@ -137,7 +137,6 @@ export function ConfigurationPage() { } }, [section, navigate]); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [restartRequiredConfigs, setRestartRequiredConfigs] = useState([]); const [isRestartBannerDismissed, setIsRestartBannerDismissed] = useState(() => { // Initialize from session storage on component mount @@ -159,7 +158,6 @@ export function ConfigurationPage() { const handleReloadConfig = async () => { try { await reloadConfig.mutateAsync(); - setHasUnsavedChanges(false); setRestartRequiredConfigs([]); setIsRestartBannerDismissed(false); sessionStorage.removeItem("restartBannerDismissed"); @@ -186,7 +184,6 @@ export function ConfigurationPage() { try { await restartServer.mutateAsync(false); // Clear local state since server is restarting - setHasUnsavedChanges(false); setRestartRequiredConfigs([]); setIsRestartBannerDismissed(false); sessionStorage.removeItem("restartBannerDismissed"); @@ -350,12 +347,6 @@ export function ConfigurationPage() {
- {hasUnsavedChanges && ( -
- UNSAVED -
- )} -
{provider.state === "connected" || provider.state === "active" ? ( - + Connected ) : provider.state === "disconnected" ? ( @@ -593,7 +591,7 @@ export function ProviderHealth() { Disconnected ) : ( - + {provider.state} )} @@ -612,10 +610,10 @@ export function ProviderHealth() { 500 - ? "bg-rose-500 shadow-[0_0_6px_rgba(244,63,94,0.6)]" + ? "bg-error" : provider.ping_ms > 200 - ? "bg-amber-400 shadow-[0_0_6px_rgba(251,191,36,0.6)]" - : "bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]" + ? "bg-warning" + : "bg-success" }`} /> {provider.error_count > 0 ? ( - + {provider.error_count} ) : ( @@ -647,10 +645,10 @@ export function ProviderHealth() { {provider.missing_count > 0 ? ( {provider.missing_count.toLocaleString()} diff --git a/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderQuota.tsx b/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderQuota.tsx index 8aa5916f2..bcca423e7 100644 --- a/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderQuota.tsx +++ b/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderQuota.tsx @@ -58,12 +58,12 @@ export function ProviderQuota() { return (
{/* Card Header with Host and Reset button */}
{getProviderBrandName(provider.host)} @@ -71,7 +71,7 @@ export function ProviderQuota() { {percentage > 0 && ( +
+ )}
)} From a448f1787ce9dfc0a2aaf8438afff95b53f758d9 Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:40:39 +0530 Subject: [PATCH 06/11] feat(arrs): merge stuck cleanup into unified queue cleanup Collapse the two overlapping arrs cleanup features into one. The rules-with-actions model (remove / blocklist / blocklist+search) is now the single message-rule list (queue_cleanup_rules), governed by one enable toggle and one grace period (default 5 min). Ghost/empty-folder detection and the Purge Automatic Failures toggle remain as separate non-message mechanisms. The former queue-cleanup allowlist is gone: its entries are equivalent to rules with action 'remove'. migrateArrsCleanup folds legacy configs (stuck rules + allowlist + enable flag + grace) into the unified model on load and drops the deprecated fields from saved YAML, so existing setups upgrade without losing custom entries. Frontend collapses the two config sections into one Queue Cleanup section. Adds migration unit tests. --- .../components/config/ArrsConfigSection.tsx | 165 +----------------- frontend/src/types/config.ts | 10 +- internal/api/types.go | 10 +- internal/arrs/service.go | 4 +- internal/arrs/worker/stuck_cleanup.go | 15 +- internal/arrs/worker/worker.go | 57 ++---- internal/config/manager.go | 113 +++++++++--- internal/config/manager_test.go | 64 +++++++ 8 files changed, 182 insertions(+), 256 deletions(-) diff --git a/frontend/src/components/config/ArrsConfigSection.tsx b/frontend/src/components/config/ArrsConfigSection.tsx index 427e1d93a..966b57353 100644 --- a/frontend/src/components/config/ArrsConfigSection.tsx +++ b/frontend/src/components/config/ArrsConfigSection.tsx @@ -61,7 +61,6 @@ export function ArrsConfigSection({ const [webhookSuccess, setWebhookSuccess] = useState(null); const [webhookError, setWebhookError] = useState(null); const [saveError, setSaveError] = useState(null); - const [newIgnoreMessage, setNewIgnoreMessage] = useState(""); const [newStuckPattern, setNewStuckPattern] = useState(""); const registerWebhooks = useRegisterArrsWebhooks(); @@ -191,35 +190,9 @@ export function ArrsConfigSection({ setShowAddInstance(false); }; - const handleAddIgnoreMessage = () => { - if (!newIgnoreMessage.trim()) return; - const currentList = formData.queue_cleanup_allowlist || []; - if (currentList.some((m) => m.message === newIgnoreMessage.trim())) { - setNewIgnoreMessage(""); - return; - } - const newList = [...currentList, { message: newIgnoreMessage.trim(), enabled: true }]; - handleFormChange("queue_cleanup_allowlist", newList); - setNewIgnoreMessage(""); - }; - - const handleRemoveIgnoreMessage = (index: number) => { - const currentList = formData.queue_cleanup_allowlist || []; - const newList = [...currentList]; - newList.splice(index, 1); - handleFormChange("queue_cleanup_allowlist", newList); - }; - - const handleToggleIgnoreMessage = (index: number) => { - const currentList = formData.queue_cleanup_allowlist || []; - const newList = [...currentList]; - newList[index] = { ...newList[index], enabled: !newList[index].enabled }; - handleFormChange("queue_cleanup_allowlist", newList); - }; - const handleAddStuckPattern = () => { if (!newStuckPattern.trim()) return; - const currentList = formData.stuck_cleanup_rules || []; + const currentList = formData.queue_cleanup_rules || []; if (currentList.some((m) => m.message === newStuckPattern.trim())) { setNewStuckPattern(""); return; @@ -228,26 +201,26 @@ export function ArrsConfigSection({ ...currentList, { message: newStuckPattern.trim(), enabled: true, action: "blocklist_search" }, ]; - handleFormChange("stuck_cleanup_rules", newList); + handleFormChange("queue_cleanup_rules", newList); setNewStuckPattern(""); }; const handleRemoveStuckPattern = (index: number) => { - const newList = [...(formData.stuck_cleanup_rules || [])]; + const newList = [...(formData.queue_cleanup_rules || [])]; newList.splice(index, 1); - handleFormChange("stuck_cleanup_rules", newList); + handleFormChange("queue_cleanup_rules", newList); }; const handleToggleStuckPattern = (index: number) => { - const newList = [...(formData.stuck_cleanup_rules || [])]; + const newList = [...(formData.queue_cleanup_rules || [])]; newList[index] = { ...newList[index], enabled: !newList[index].enabled }; - handleFormChange("stuck_cleanup_rules", newList); + handleFormChange("queue_cleanup_rules", newList); }; const handleSetStuckRuleAction = (index: number, action: StuckCleanupAction) => { - const newList = [...(formData.stuck_cleanup_rules || [])]; + const newList = [...(formData.queue_cleanup_rules || [])]; newList[index] = { ...newList[index], action }; - handleFormChange("stuck_cleanup_rules", newList); + handleFormChange("queue_cleanup_rules", newList); }; const handleSave = async () => { @@ -482,126 +455,6 @@ export function ArrsConfigSection({
-
-
- Allowlist (Ignore Errors) -
-
- {(formData.queue_cleanup_allowlist || []).map((msg, index) => ( -
-
- handleToggleIgnoreMessage(index)} - disabled={isReadOnly} - /> - - {msg.message} - -
- -
- ))} -
- - {!isReadOnly && ( -
- setNewIgnoreMessage(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAddIgnoreMessage()} - /> - -
- )} -
-
- )} - -
- - {/* Stuck Import Cleanup — own section */} -
-

- Queue Cleanup -

-
-
- -
-
-
- Auto-Resolve Stuck Imports -
-

- Automatically clears imports that get stuck for a known reason — runs on a - schedule or on demand from the Queue page. -

-
- handleFormChange("stuck_cleanup_enabled", e.target.checked)} - disabled={isReadOnly} - /> -
- - {(formData.stuck_cleanup_enabled ?? false) && ( -
-
- - Stuck Grace Period - -
- - handleFormChange( - "stuck_cleanup_grace_period_minutes", - Number.parseInt(e.target.value, 10) || 5, - ) - } - min={1} - disabled={isReadOnly} - /> - - min - -
-
- How long an import must stay stuck before a rule kicks in. Short-lived errors - that resolve on their own are left alone. -
-
-
Error Rules

@@ -609,7 +462,7 @@ export function ArrsConfigSection({ blocklist, or blocklist + search.

- {(formData.stuck_cleanup_rules || []).map((rule, index) => ( + {(formData.queue_cleanup_rules || []).map((rule, index) => ( +
+ How often the *arr queues are checked. +
@@ -411,11 +414,11 @@ export function ArrsConfigSection({ handleFormChange( "queue_cleanup_grace_period_minutes", - Number.parseInt(e.target.value, 10) || 10, + Number.parseInt(e.target.value, 10) || 5, ) } min={0} @@ -426,7 +429,8 @@ export function ArrsConfigSection({
- Wait time before considering a failed item "stuck" and eligible for cleanup. + How long a stuck or failed import must persist before cleanup acts on it. + Brief errors that clear on their own are ignored.
From b9328d1a21db245eb1a44c2076c3fe7d6c9b673d Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:31:40 +0530 Subject: [PATCH 09/11] fix(arrs): recognize renamed AltMount download client + case-insensitive auto-import match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue cleanup gated every pass on the download client name matching the literal "AltMount (SABnzbd)". Users who add the SABnzbd client manually under another name (e.g. "Altmount") were never recognized, so cleanup skipped all their items. Add registrar.IsAltmountDownloadClient (case- insensitive "altmount" match) and use it in all four ownership checks. Also match the 'automatic import is not possible' phrase case-insensitively in the auto-failure purge — *arrs emit it mid-sentence (e.g. 'Movie title mismatch, automatic import is not possible.'), which the exact-case check missed. Add a unit test for the client-name matcher. --- internal/arrs/registrar/manager.go | 11 +++++++++++ internal/arrs/registrar/manager_test.go | 25 +++++++++++++++++++++++++ internal/arrs/worker/stuck_cleanup.go | 2 +- internal/arrs/worker/worker.go | 21 ++++++++++++--------- 4 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 internal/arrs/registrar/manager_test.go diff --git a/internal/arrs/registrar/manager.go b/internal/arrs/registrar/manager.go index 53c613d7b..6b0f732ba 100644 --- a/internal/arrs/registrar/manager.go +++ b/internal/arrs/registrar/manager.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "github.com/javi11/altmount/internal/arrs/clients" "github.com/javi11/altmount/internal/arrs/instances" @@ -20,6 +21,16 @@ import ( // queue items from those owned by other download clients. const AltmountDownloadClientName = "AltMount (SABnzbd)" +// IsAltmountDownloadClient reports whether a download client name belongs to +// AltMount. AltMount auto-registers under AltmountDownloadClientName, but users +// frequently add the SABnzbd client manually under a different name (e.g. +// "Altmount"), so queue cleanup matches case-insensitively on the "altmount" +// token rather than requiring the exact registered name — otherwise it would +// never recognize, and never clean up, items owned by a renamed client. +func IsAltmountDownloadClient(name string) bool { + return strings.Contains(strings.ToLower(name), "altmount") +} + type Manager struct { instances *instances.Manager clients *clients.Manager diff --git a/internal/arrs/registrar/manager_test.go b/internal/arrs/registrar/manager_test.go new file mode 100644 index 000000000..a57cd1e08 --- /dev/null +++ b/internal/arrs/registrar/manager_test.go @@ -0,0 +1,25 @@ +package registrar + +import "testing" + +func TestIsAltmountDownloadClient(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {AltmountDownloadClientName, true}, // exact registered name + {"Altmount", true}, // common manual name + {"altmount", true}, // lowercase + {"AltMount (SABnzbd)", true}, + {"My AltMount SAB", true}, + {"", false}, + {"qBittorrent", false}, + {"SABnzbd", false}, + {"NZBGet", false}, + } + for _, tt := range tests { + if got := IsAltmountDownloadClient(tt.name); got != tt.want { + t.Errorf("IsAltmountDownloadClient(%q) = %v, want %v", tt.name, got, tt.want) + } + } +} diff --git a/internal/arrs/worker/stuck_cleanup.go b/internal/arrs/worker/stuck_cleanup.go index 71a5e6074..800da400f 100644 --- a/internal/arrs/worker/stuck_cleanup.go +++ b/internal/arrs/worker/stuck_cleanup.go @@ -147,7 +147,7 @@ func (w *Worker) selectStuckActions(ctx context.Context, instance *model.ConfigI for _, item := range items { // Only ever touch items owned by AltMount's download client — other // clients may reference paths AltMount cannot see (see issue #523). - if item.DownloadClient != registrar.AltmountDownloadClientName { + if !registrar.IsAltmountDownloadClient(item.DownloadClient) { continue } diff --git a/internal/arrs/worker/worker.go b/internal/arrs/worker/worker.go index 66180593c..48ce28d6b 100644 --- a/internal/arrs/worker/worker.go +++ b/internal/arrs/worker/worker.go @@ -211,7 +211,7 @@ func (w *Worker) cleanupRadarrQueue(ctx context.Context, instance *model.ConfigI // Only operate on queue items owned by AltMount's registered download client. // Items from other clients (qBittorrent, real SABnzbd, etc.) may reference // paths AltMount cannot see and must never be touched — see issue #523. - if q.DownloadClient != registrar.AltmountDownloadClientName { + if !registrar.IsAltmountDownloadClient(q.DownloadClient) { continue } @@ -244,9 +244,10 @@ func (w *Worker) cleanupRadarrQueue(ctx context.Context, instance *model.ConfigI allMessages := strings.Join(msg.Messages, " ") // Automatic import failure cleanup (configurable). Message-based rules are - // handled separately by CleanupStuckQueue. + // handled separately by CleanupStuckQueue. Match case-insensitively: *arrs + // emit the phrase mid-sentence (e.g. "...automatic import is not possible."). if cfg.Arrs.CleanupAutomaticImportFailure != nil && *cfg.Arrs.CleanupAutomaticImportFailure && - strings.Contains(allMessages, "Automatic import is not possible") { + strings.Contains(strings.ToLower(allMessages), "automatic import is not possible") { shouldCleanup = true break } @@ -329,7 +330,7 @@ func (w *Worker) cleanupSonarrQueue(ctx context.Context, instance *model.ConfigI // Only operate on queue items owned by AltMount's registered download client. // Items from other clients (qBittorrent, real SABnzbd, etc.) may reference // paths AltMount cannot see and must never be touched — see issue #523. - if q.DownloadClient != registrar.AltmountDownloadClientName { + if !registrar.IsAltmountDownloadClient(q.DownloadClient) { continue } @@ -362,9 +363,10 @@ func (w *Worker) cleanupSonarrQueue(ctx context.Context, instance *model.ConfigI allMessages := strings.Join(msg.Messages, " ") // Automatic import failure cleanup (configurable). Message-based rules are - // handled separately by CleanupStuckQueue. + // handled separately by CleanupStuckQueue. Match case-insensitively: *arrs + // emit the phrase mid-sentence (e.g. "...automatic import is not possible."). if cfg.Arrs.CleanupAutomaticImportFailure != nil && *cfg.Arrs.CleanupAutomaticImportFailure && - strings.Contains(allMessages, "Automatic import is not possible") { + strings.Contains(strings.ToLower(allMessages), "automatic import is not possible") { shouldCleanup = true break } @@ -478,7 +480,7 @@ func (w *Worker) cleanupSportarrQueue(ctx context.Context, instance *model.Confi // Only operate on queue items owned by AltMount's registered download client. // Items from other clients may reference paths AltMount cannot see and must // never be touched — see issue #523. - if q.DownloadClient.Name != registrar.AltmountDownloadClientName { + if !registrar.IsAltmountDownloadClient(q.DownloadClient.Name) { continue } @@ -520,9 +522,10 @@ func (w *Worker) cleanupSportarrQueue(ctx context.Context, instance *model.Confi allMessages := strings.Join(msg.Messages, " ") // Automatic import failure cleanup (configurable). Message-based rules are - // handled separately by CleanupStuckQueue. + // handled separately by CleanupStuckQueue. Match case-insensitively: *arrs + // emit the phrase mid-sentence (e.g. "...automatic import is not possible."). if cfg.Arrs.CleanupAutomaticImportFailure != nil && *cfg.Arrs.CleanupAutomaticImportFailure && - strings.Contains(allMessages, "Automatic import is not possible") { + strings.Contains(strings.ToLower(allMessages), "automatic import is not possible") { shouldCleanup = true break } From 60a875338240b559d4ea461856eaa63b65d4dc41 Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:28:10 +0530 Subject: [PATCH 10/11] feat(arrs): remove Import Failure Cleanup toggle; unify queue cleanup across all arrs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the cleanup_automatic_import_failure toggle — it was a hardcoded single-phrase remove-only rule that duplicated the unified rules system, ran before the rule pass (shadowing user rules), and only covered radarr/sonarr/sportarr. - Fold it into the rules: seed a default 'automatic import is not possible' rule (disabled, action remove). migrateArrsCleanup enables a matching/ substring rule (or appends one) when the legacy toggle was on, then drops the flag. Field kept deprecated for one-time migration only. - Unify ghost/empty-folder detection into the single all-types cleanup pass: stuckItem now carries OutputPath, and ghost detection runs grace-free before rule matching in selectStuckActions. Deletes the old radarr/sonarr/sportarr-only CleanupQueue path. Whisparr/Lidarr/Readarr now get ghost cleanup too, and the auto-failure phrase is handled for all six via the rule pass. - Drop the field from the API response and frontend; show Cleanup Interval and Grace Period side-by-side. Add migration tests (append / enable-existing / enable-substring / false-clears). - Docs: sync config.sample.yaml and the integration guide to the unified queue_cleanup_rules model (drop the deprecated toggle and allowlist refs, document the rule actions, correct the grace-period default to 5). --- config.sample.yaml | 23 +- docs/docs/3. Configuration/integration.md | 37 +- .../components/config/ArrsConfigSection.tsx | 72 +-- frontend/src/types/config.ts | 1 - internal/api/types.go | 2 - internal/arrs/service.go | 5 - internal/arrs/worker/stuck_cleanup.go | 40 +- internal/arrs/worker/worker.go | 445 +----------------- internal/config/manager.go | 130 +++-- internal/config/manager_test.go | 95 ++++ 10 files changed, 285 insertions(+), 565 deletions(-) diff --git a/config.sample.yaml b/config.sample.yaml index a2e7a2408..055cea2fa 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -219,15 +219,22 @@ arrs: lidarr_instances: [] # Lidarr instances (configured via UI) readarr_instances: [] # Readarr instances (configured via UI) whisparr_instances: [] # Whisparr instances (configured via UI) - # Queue Cleanup Configuration - queue_cleanup_enabled: true # Enable automatic queue cleanup for failed imports (default: true) - queue_cleanup_interval_seconds: 10 # Interval in seconds to check for failed imports (default: 10) - cleanup_automatic_import_failure: false # Enable cleanup of "Automatic import is not possible" errors (default: false) - queue_cleanup_allowlist: [] # List of additional error messages to treat as safe for cleanup + # Queue Cleanup Configuration (covers ghost/empty-folder removal + message rules for + # all *arr types: radarr/sonarr/whisparr/lidarr/readarr/sportarr) + queue_cleanup_enabled: true # Enable automatic queue cleanup for failed/stuck imports (default: true) + queue_cleanup_interval_seconds: 10 # Interval in seconds to check the *arr queues (default: 10) + queue_cleanup_grace_period_minutes: 5 # Minutes an item must stay stuck before cleanup acts (default: 5) + # Message rules: when a stuck import's *arr status message matches a rule, run its + # action (action: remove | blocklist | blocklist_search). Manage these in the web UI. + queue_cleanup_rules: [] # Example: - # queue_cleanup_allowlist: - # - "Not a Custom Format upgrade" - # - "Ignored Message 2" + # queue_cleanup_rules: + # - message: "Sample" + # enabled: true + # action: blocklist_search + # - message: "automatic import is not possible" + # enabled: false + # action: remove # Example instance configuration (use the web UI instead): # radarr_instances: # - name: "radarr-main" diff --git a/docs/docs/3. Configuration/integration.md b/docs/docs/3. Configuration/integration.md index f1bb3d064..2a6283909 100644 --- a/docs/docs/3. Configuration/integration.md +++ b/docs/docs/3. Configuration/integration.md @@ -149,7 +149,11 @@ With this layout, both containers see the same paths, so `import_dir: /mnt/symli ## Step 6: Configure Queue Cleanup -AltMount can automatically monitor ARR queues and remove failed imports to keep things tidy: +AltMount can automatically monitor ARR queues and clean up stuck or failed imports to keep +things tidy. One pass covers all *arr types (Radarr, Sonarr, Whisparr, Lidarr, Readarr, +Sportarr): it removes ghost/empty-folder entries (already imported, or the source path is +gone) and applies your message rules to stuck imports. Only queue items owned by AltMount's +download client are ever touched. ```yaml arrs: @@ -157,23 +161,28 @@ arrs: webhook_base_url: "" # Base URL for webhook callbacks (defaults to http://:) queue_cleanup_enabled: true queue_cleanup_interval_seconds: 300 - queue_cleanup_grace_period_minutes: 10 # Wait before cleaning up (default: 10) - cleanup_automatic_import_failure: true - queue_cleanup_allowlist: - - message: "Not a Custom Format upgrade" + queue_cleanup_grace_period_minutes: 5 # Wait before cleaning up (default: 5) + # Message rules: when a stuck import's *arr status message matches a rule, run its + # action (remove | blocklist | blocklist_search). Manage these in the web UI. + queue_cleanup_rules: + - message: "Sample" enabled: true - - message: "No files found are eligible" + action: blocklist_search + - message: "Not a Custom Format upgrade" enabled: true + action: remove + - message: "automatic import is not possible" + enabled: false + action: remove ``` -| Field | Description | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------- | -| `queue_cleanup_enabled` | Enable or disable automatic queue cleanup | -| `queue_cleanup_interval_seconds` | How often to check ARR queues (in seconds) | -| `queue_cleanup_grace_period_minutes` | Minimum age (in minutes) before a failed item is cleaned up (default: 10) | -| `cleanup_automatic_import_failure` | Clean up items with "Automatic import is not possible" errors | -| `webhook_base_url` | Base URL ARRs use to reach AltMount for webhooks (default: `http://:`) | -| `queue_cleanup_allowlist` | Error messages to treat as safe for cleanup. Each entry has a `message` string and an `enabled` boolean | +| Field | Description | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `queue_cleanup_enabled` | Enable or disable automatic queue cleanup | +| `queue_cleanup_interval_seconds` | How often to check ARR queues (in seconds) | +| `queue_cleanup_grace_period_minutes` | Minimum age (in minutes) a stuck/failed item must persist before it is cleaned up (default: 5). Ghost/empty-folder removal is immediate. | +| `webhook_base_url` | Base URL ARRs use to reach AltMount for webhooks (default: `http://:`) | +| `queue_cleanup_rules` | Message rules for stuck imports. Each rule has a `message` substring (case-insensitive), an `enabled` boolean, and an `action`: `remove`, `blocklist`, or `blocklist_search`. | ## Verifying the Setup diff --git a/frontend/src/components/config/ArrsConfigSection.tsx b/frontend/src/components/config/ArrsConfigSection.tsx index aa341a0fc..a6e951b1d 100644 --- a/frontend/src/components/config/ArrsConfigSection.tsx +++ b/frontend/src/components/config/ArrsConfigSection.tsx @@ -379,82 +379,58 @@ export function ArrsConfigSection({ {(formData.queue_cleanup_enabled ?? true) && (
-
- Cleanup Interval -
- - handleFormChange( - "queue_cleanup_interval_seconds", - Number.parseInt(e.target.value, 10) || 10, - ) - } - min={1} - max={3600} - disabled={isReadOnly} - /> - - sec - -
-
- How often the *arr queues are checked. -
-
-
- - Cleanup Grace Period - + Cleanup Interval
handleFormChange( - "queue_cleanup_grace_period_minutes", - Number.parseInt(e.target.value, 10) || 5, + "queue_cleanup_interval_seconds", + Number.parseInt(e.target.value, 10) || 10, ) } - min={0} + min={1} + max={3600} disabled={isReadOnly} /> - min + sec
- How long a stuck or failed import must persist before cleanup acts on it. - Brief errors that clear on their own are ignored. + How often the *arr queues are checked.
- Import Failure Cleanup + Cleanup Grace Period -
diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index f8950c65f..55a49521f 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -640,7 +640,6 @@ export interface ArrsConfig { queue_cleanup_enabled?: boolean; queue_cleanup_interval_seconds?: number; queue_cleanup_grace_period_minutes?: number; - cleanup_automatic_import_failure?: boolean; queue_cleanup_rules?: StuckCleanupRule[]; } diff --git a/internal/api/types.go b/internal/api/types.go index 9b446732e..e456faf43 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -182,7 +182,6 @@ type ArrsAPIResponse struct { SportarrInstances []ArrsInstanceAPIResponse `json:"sportarr_instances"` QueueCleanupEnabled bool `json:"queue_cleanup_enabled,omitempty"` QueueCleanupIntervalSeconds int `json:"queue_cleanup_interval_seconds,omitempty"` - CleanupAutomaticImportFailure bool `json:"cleanup_automatic_import_failure,omitempty"` QueueCleanupGracePeriodMinutes int `json:"queue_cleanup_grace_period_minutes,omitempty"` QueueCleanupRules []config.StuckCleanupRule `json:"queue_cleanup_rules,omitempty"` } @@ -365,7 +364,6 @@ func ToConfigAPIResponse(cfg *config.Config, apiKey string) *ConfigAPIResponse { SportarrInstances: toArrsInstances(cfg.Arrs.SportarrInstances), QueueCleanupEnabled: cfg.Arrs.QueueCleanupEnabled != nil && *cfg.Arrs.QueueCleanupEnabled, QueueCleanupIntervalSeconds: cfg.Arrs.QueueCleanupIntervalSeconds, - CleanupAutomaticImportFailure: cfg.Arrs.CleanupAutomaticImportFailure != nil && *cfg.Arrs.CleanupAutomaticImportFailure, QueueCleanupGracePeriodMinutes: cfg.Arrs.QueueCleanupGracePeriodMinutes, QueueCleanupRules: cfg.Arrs.QueueCleanupRules, } diff --git a/internal/arrs/service.go b/internal/arrs/service.go index 4f02521aa..679125036 100644 --- a/internal/arrs/service.go +++ b/internal/arrs/service.go @@ -223,11 +223,6 @@ func (s *Service) RegisterConfigChangeHandler(ctx context.Context, configManager }) } -// CleanupQueue checks all ARR instances for importPending items with empty folders -func (s *Service) CleanupQueue(ctx context.Context) error { - return s.worker.CleanupQueue(ctx) -} - // TriggerFileRescan triggers a rescan for a specific file path through the appropriate ARR instance func (s *Service) TriggerFileRescan(ctx context.Context, pathForRescan string, relativePath string, metadataStr *string) error { return s.scanner.TriggerFileRescan(ctx, pathForRescan, relativePath, metadataStr) diff --git a/internal/arrs/worker/stuck_cleanup.go b/internal/arrs/worker/stuck_cleanup.go index 800da400f..5a1dca02c 100644 --- a/internal/arrs/worker/stuck_cleanup.go +++ b/internal/arrs/worker/stuck_cleanup.go @@ -36,6 +36,7 @@ type stuckItem struct { TrackedDownloadStatus string TrackedDownloadState string // empty when the *arr does not report it (e.g. Lidarr) DownloadClient string + OutputPath string // download path; used for ghost detection (may be empty) Messages []string } @@ -136,10 +137,12 @@ func stuckRuleFor(item stuckItem, cfg *config.Config) *config.StuckCleanupRule { } // selectStuckActions filters AltMount-owned queue items to those that should be -// cleaned now, carrying each item's blocklist decision. With force, all matching -// items are returned immediately. Otherwise an item must have been observed stuck -// for the configured grace period; first observations and items the *arr has since -// resolved are tracked/cleared via the shared firstSeen map. +// cleaned now, carrying each item's action. Ghost/empty-folder items (already +// imported, or source path gone) are removed first, grace-free. The remainder are +// matched against the message rules: with force, all matching items are returned +// immediately; otherwise an item must have been observed stuck for the configured +// grace period. First observations and items the *arr has since resolved are +// tracked/cleared via the shared firstSeen map. func (w *Worker) selectStuckActions(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, items []stuckItem, force bool) []stuckAction { var actions []stuckAction gracePeriod := time.Duration(cfg.Arrs.QueueCleanupGracePeriodMinutes) * time.Minute @@ -153,6 +156,20 @@ func (w *Worker) selectStuckActions(ctx context.Context, instance *model.ConfigI key := fmt.Sprintf("stuck|%s|%d", instance.Name, item.ID) + // Ghost detection runs before rule matching and is grace-free: an item whose + // file is already in the library (import history) or whose source path is gone + // is removed immediately. isGhostByPathGone keeps its own observation window via + // firstSeen. Ghosts are always removed (never blocklisted) — the release was not + // bad, the queue entry is just stale. + if w.checkGhostByImportHistory(ctx, item.OutputPath, cfg, instance.Name, item.Title) || + w.isGhostByPathGone(ctx, item.OutputPath, item.ID, cfg, instance.Name, item.Title) { + actions = append(actions, stuckAction{ID: item.ID, Action: config.StuckActionRemove}) + w.firstSeenMu.Lock() + delete(w.firstSeen, key) + w.firstSeenMu.Unlock() + continue + } + rule := stuckRuleFor(item, cfg) if rule == nil { w.firstSeenMu.Lock() @@ -264,6 +281,7 @@ func (w *Worker) cleanupStuckRadarr(ctx context.Context, instance *model.ConfigI TrackedDownloadStatus: q.TrackedDownloadStatus, TrackedDownloadState: q.TrackedDownloadState, DownloadClient: q.DownloadClient, + OutputPath: q.OutputPath, Messages: flattenStarrMessages(q.StatusMessages), }) } @@ -299,6 +317,7 @@ func (w *Worker) cleanupStuckSonarr(ctx context.Context, instance *model.ConfigI TrackedDownloadStatus: q.TrackedDownloadStatus, TrackedDownloadState: q.TrackedDownloadState, DownloadClient: q.DownloadClient, + OutputPath: q.OutputPath, Messages: flattenStarrMessages(q.StatusMessages), }) } @@ -326,6 +345,7 @@ func (w *Worker) cleanupStuckLidarr(ctx context.Context, instance *model.ConfigI Title: q.Title, TrackedDownloadStatus: q.TrackedDownloadStatus, DownloadClient: q.DownloadClient, + OutputPath: q.OutputPath, Messages: flattenStarrMessages(q.StatusMessages), }) } @@ -352,6 +372,7 @@ func (w *Worker) cleanupStuckReadarr(ctx context.Context, instance *model.Config TrackedDownloadStatus: q.TrackedDownloadStatus, TrackedDownloadState: q.TrackedDownloadState, DownloadClient: q.DownloadClient, + OutputPath: q.OutputPath, Messages: flattenStarrMessages(q.StatusMessages), }) } @@ -374,6 +395,16 @@ func (w *Worker) cleanupStuckSportarr(ctx context.Context, instance *model.Confi items := make([]stuckItem, 0, len(queue)) for _, q := range queue { + // Capture the indexer from Sportarr's native queue. Sportarr is not + // starr-compatible, so AltMount cannot auto-register its Grab/Import webhook + // (the path that supplies the indexer for Radarr/Sonarr/etc.). Its native queue + // record is the only place the indexer is exposed, so persist it here against + // the download ID — otherwise these imports show up in indexer health as + // "Unknown". + if q.DownloadID != "" && q.Indexer != "" { + w.captureSportarrIndexer(ctx, q.DownloadID, q.Indexer) + } + var messages []string for _, m := range q.StatusMessages { if m.Title != "" { @@ -387,6 +418,7 @@ func (w *Worker) cleanupStuckSportarr(ctx context.Context, instance *model.Confi TrackedDownloadStatus: q.TrackedDownloadStatus, TrackedDownloadState: q.TrackedDownloadState, DownloadClient: q.DownloadClient.Name, + OutputPath: q.OutputPath, Messages: messages, }) } diff --git a/internal/arrs/worker/worker.go b/internal/arrs/worker/worker.go index 48ce28d6b..0c663d47b 100644 --- a/internal/arrs/worker/worker.go +++ b/internal/arrs/worker/worker.go @@ -12,11 +12,8 @@ import ( "github.com/javi11/altmount/internal/arrs/clients" "github.com/javi11/altmount/internal/arrs/instances" - "github.com/javi11/altmount/internal/arrs/model" - "github.com/javi11/altmount/internal/arrs/registrar" "github.com/javi11/altmount/internal/config" "github.com/javi11/altmount/internal/database" - "golift.io/starr" ) type Worker struct { @@ -135,15 +132,14 @@ func (w *Worker) safeCleanup() { slog.Error("Panic in queue cleanup", "panic", r) } }() - if err := w.CleanupQueue(w.workerCtx); err != nil { - slog.Error("Queue cleanup failed", "error", err) + if !IsQueueCleanupEnabled(w.configGetter()) { + return } - // The message-rule pass runs on the same tick. force=false so it observes items - // over time and only acts on those stuck past the grace period. - if IsQueueCleanupEnabled(w.configGetter()) { - if _, err := w.CleanupStuckQueue(w.workerCtx, false); err != nil { - slog.Error("Stuck import cleanup failed", "error", err) - } + // One unified pass per tick covers all six *arr types: ghost/empty-folder removal, + // then the message-rule actions. force=false so items are observed over time and + // only acted on once stuck past the grace period (ghost removal stays grace-free). + if _, err := w.CleanupStuckQueue(w.workerCtx, false); err != nil { + slog.Error("Queue cleanup failed", "error", err) } } @@ -159,280 +155,6 @@ func IsQueueCleanupEnabled(cfg *config.Config) bool { return true } -// CleanupQueue checks all ARR instances for importPending items with empty folders -// and removes them from the queue after deleting the empty folder -func (w *Worker) CleanupQueue(ctx context.Context) error { - cfg := w.configGetter() - if !IsQueueCleanupEnabled(cfg) { - return nil - } - instances := w.instances.GetAllInstances() - - for _, instance := range instances { - if !instance.Enabled { - continue - } - - switch instance.Type { - case "radarr": - if err := w.cleanupRadarrQueue(ctx, instance, cfg); err != nil { - slog.WarnContext(ctx, "Failed to cleanup Radarr queue", - "instance", instance.Name, "error", err) - } - case "sonarr": - if err := w.cleanupSonarrQueue(ctx, instance, cfg); err != nil { - slog.WarnContext(ctx, "Failed to cleanup Sonarr queue", - "instance", instance.Name, "error", err) - } - case "sportarr": - if err := w.cleanupSportarrQueue(ctx, instance, cfg); err != nil { - slog.WarnContext(ctx, "Failed to cleanup Sportarr queue", - "instance", instance.Name, "error", err) - } - } - } - - return nil -} - -func (w *Worker) cleanupRadarrQueue(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config) error { - client, err := w.clients.GetOrCreateRadarrClient(instance.Name, instance.URL, instance.APIKey) - if err != nil { - return fmt.Errorf("failed to get Radarr client: %w", err) - } - - queue, err := client.GetQueueContext(ctx, 0, 500) - if err != nil { - return fmt.Errorf("failed to get Radarr queue: %w", err) - } - - var idsToRemove []int64 - for _, q := range queue.Records { - // Only operate on queue items owned by AltMount's registered download client. - // Items from other clients (qBittorrent, real SABnzbd, etc.) may reference - // paths AltMount cannot see and must never be touched — see issue #523. - if !registrar.IsAltmountDownloadClient(q.DownloadClient) { - continue - } - - // Strategy 1: Ghost detection — cleanup already-imported files - if w.checkGhostByImportHistory(ctx, q.OutputPath, cfg, instance.Name, q.Title) { - idsToRemove = append(idsToRemove, q.ID) - continue - } - - // Fallback: path-gone check with safety guards - if w.isGhostByPathGone(ctx, q.OutputPath, q.ID, cfg, instance.Name, q.Title) { - idsToRemove = append(idsToRemove, q.ID) - continue - } - - // Strategy 2: Graceful cleanup for blocked/failed imports - // Check for completed items with warning status that are pending import - if q.Status != "completed" || q.TrackedDownloadStatus != "warning" || (q.TrackedDownloadState != "importPending" && q.TrackedDownloadState != "importBlocked") { - continue - } - - // Check if path is within managed directories (import_dir, mount_path, or complete_dir) - if !w.isPathManaged(q.OutputPath, cfg) { - continue - } - - // Check status messages for known issues - shouldCleanup := false - for _, msg := range q.StatusMessages { - allMessages := strings.Join(msg.Messages, " ") - - // Automatic import failure cleanup (configurable). Message-based rules are - // handled separately by CleanupStuckQueue. Match case-insensitively: *arrs - // emit the phrase mid-sentence (e.g. "...automatic import is not possible."). - if cfg.Arrs.CleanupAutomaticImportFailure != nil && *cfg.Arrs.CleanupAutomaticImportFailure && - strings.Contains(strings.ToLower(allMessages), "automatic import is not possible") { - shouldCleanup = true - break - } - } - - if shouldCleanup { - key := fmt.Sprintf("%s|%d", instance.Name, q.ID) - w.firstSeenMu.Lock() - seenTime, exists := w.firstSeen[key] - if !exists { - w.firstSeen[key] = time.Now() - w.firstSeenMu.Unlock() - slog.DebugContext(ctx, "First saw failed import pending item, starting grace period", - "path", q.OutputPath, "title", q.Title, "instance", instance.Name) - continue - } - w.firstSeenMu.Unlock() - - gracePeriod := time.Duration(cfg.Arrs.QueueCleanupGracePeriodMinutes) * time.Minute - if time.Since(seenTime) < gracePeriod { - slog.DebugContext(ctx, "Item still in grace period", - "path", q.OutputPath, "title", q.Title, "instance", instance.Name, - "remaining", gracePeriod-time.Since(seenTime)) - continue - } - - slog.InfoContext(ctx, "Found failed import pending item after grace period", - "path", q.OutputPath, "title", q.Title, "instance", instance.Name) - idsToRemove = append(idsToRemove, q.ID) - - w.firstSeenMu.Lock() - delete(w.firstSeen, key) - w.firstSeenMu.Unlock() - } else { - // If it's no longer matching failure criteria, remove from tracking - key := fmt.Sprintf("%s|%d", instance.Name, q.ID) - w.firstSeenMu.Lock() - delete(w.firstSeen, key) - w.firstSeenMu.Unlock() - } - } - - // Remove from ARR queue with removeFromClient and blocklist flags - if len(idsToRemove) > 0 { - removeFromClient := true - opts := &starr.QueueDeleteOpts{ - RemoveFromClient: &removeFromClient, - BlockList: false, - SkipRedownload: false, - } - for _, id := range idsToRemove { - if err := client.DeleteQueueContext(ctx, id, opts); err != nil { - if strings.Contains(err.Error(), "404") { - slog.DebugContext(ctx, "Queue item already removed from Radarr", "id", id) - } else { - slog.ErrorContext(ctx, "Failed to delete queue item", - "id", id, "error", err) - } - } - } - slog.InfoContext(ctx, "Cleaned up Radarr queue items", - "instance", instance.Name, "count", len(idsToRemove)) - } - return nil -} - -func (w *Worker) cleanupSonarrQueue(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config) error { - client, err := w.clients.GetOrCreateSonarrClient(instance.Name, instance.URL, instance.APIKey) - if err != nil { - return fmt.Errorf("failed to get Sonarr client: %w", err) - } - - queue, err := client.GetQueueContext(ctx, 0, 500) - if err != nil { - return fmt.Errorf("failed to get Sonarr queue: %w", err) - } - - var idsToRemove []int64 - for _, q := range queue.Records { - // Only operate on queue items owned by AltMount's registered download client. - // Items from other clients (qBittorrent, real SABnzbd, etc.) may reference - // paths AltMount cannot see and must never be touched — see issue #523. - if !registrar.IsAltmountDownloadClient(q.DownloadClient) { - continue - } - - // Strategy 1: Immediate cleanup for already imported files - if w.checkGhostByImportHistory(ctx, q.OutputPath, cfg, instance.Name, q.Title) { - idsToRemove = append(idsToRemove, q.ID) - continue - } - - // Fallback: path-gone check with safety guards - if w.isGhostByPathGone(ctx, q.OutputPath, q.ID, cfg, instance.Name, q.Title) { - idsToRemove = append(idsToRemove, q.ID) - continue - } - - // Strategy 2: Graceful cleanup for blocked/failed imports - // Check for completed items with warning status that are pending import - if q.Protocol != "usenet" || q.Status != "completed" || q.TrackedDownloadStatus != "warning" || (q.TrackedDownloadState != "importPending" && q.TrackedDownloadState != "importBlocked") { - continue - } - - // Check if path is within managed directories (import_dir, mount_path, or complete_dir) - if !w.isPathManaged(q.OutputPath, cfg) { - continue - } - - // Check status messages for known issues - shouldCleanup := false - for _, msg := range q.StatusMessages { - allMessages := strings.Join(msg.Messages, " ") - - // Automatic import failure cleanup (configurable). Message-based rules are - // handled separately by CleanupStuckQueue. Match case-insensitively: *arrs - // emit the phrase mid-sentence (e.g. "...automatic import is not possible."). - if cfg.Arrs.CleanupAutomaticImportFailure != nil && *cfg.Arrs.CleanupAutomaticImportFailure && - strings.Contains(strings.ToLower(allMessages), "automatic import is not possible") { - shouldCleanup = true - break - } - } - - if shouldCleanup { - key := fmt.Sprintf("%s|%d", instance.Name, q.ID) - w.firstSeenMu.Lock() - seenTime, exists := w.firstSeen[key] - if !exists { - w.firstSeen[key] = time.Now() - w.firstSeenMu.Unlock() - slog.DebugContext(ctx, "First saw failed import pending item, starting grace period", - "path", q.OutputPath, "title", q.Title, "instance", instance.Name) - continue - } - w.firstSeenMu.Unlock() - - gracePeriod := time.Duration(cfg.Arrs.QueueCleanupGracePeriodMinutes) * time.Minute - if time.Since(seenTime) < gracePeriod { - slog.DebugContext(ctx, "Item still in grace period", - "path", q.OutputPath, "title", q.Title, "instance", instance.Name, - "remaining", gracePeriod-time.Since(seenTime)) - continue - } - - slog.InfoContext(ctx, "Found failed import pending item after grace period", - "path", q.OutputPath, "title", q.Title, "instance", instance.Name) - idsToRemove = append(idsToRemove, q.ID) - - w.firstSeenMu.Lock() - delete(w.firstSeen, key) - w.firstSeenMu.Unlock() - } else { - // If it's no longer matching failure criteria, remove from tracking - key := fmt.Sprintf("%s|%d", instance.Name, q.ID) - w.firstSeenMu.Lock() - delete(w.firstSeen, key) - w.firstSeenMu.Unlock() - } - } - - // Remove from ARR queue with removeFromClient and blocklist flags - if len(idsToRemove) > 0 { - removeFromClient := true - opts := &starr.QueueDeleteOpts{ - RemoveFromClient: &removeFromClient, - BlockList: false, - SkipRedownload: false, - } - for _, id := range idsToRemove { - if err := client.DeleteQueueContext(ctx, id, opts); err != nil { - if strings.Contains(err.Error(), "404") { - slog.DebugContext(ctx, "Queue item already removed from Sonarr", "id", id) - } else { - slog.ErrorContext(ctx, "Failed to delete queue item", - "id", id, "error", err) - } - } - } - slog.InfoContext(ctx, "Cleaned up Sonarr queue items", - "instance", instance.Name, "count", len(idsToRemove)) - } - return nil -} - // captureSportarrIndexer persists the indexer reported by a Sportarr queue record // against its download ID, so completed/failed imports are attributed correctly in // indexer health instead of falling back to "Unknown". The queue row is only written @@ -461,125 +183,6 @@ func (w *Worker) captureSportarrIndexer(ctx context.Context, downloadID, indexer } } -// cleanupSportarrQueue mirrors cleanupSonarrQueue but talks to Sportarr's native -// API via the thin client (Sportarr is not starr-compatible). It reuses the same -// ghost-detection, allowlist and grace-period logic. -func (w *Worker) cleanupSportarrQueue(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config) error { - client, err := w.clients.GetOrCreateSportarrClient(instance.Name, instance.URL, instance.APIKey) - if err != nil { - return fmt.Errorf("failed to get Sportarr client: %w", err) - } - - queue, err := client.GetQueue(ctx) - if err != nil { - return fmt.Errorf("failed to get Sportarr queue: %w", err) - } - - var idsToRemove []int64 - for _, q := range queue { - // Only operate on queue items owned by AltMount's registered download client. - // Items from other clients may reference paths AltMount cannot see and must - // never be touched — see issue #523. - if !registrar.IsAltmountDownloadClient(q.DownloadClient.Name) { - continue - } - - // Capture the indexer from Sportarr's native queue. Sportarr is not - // starr-compatible, so AltMount cannot auto-register its Grab/Import - // webhook (the path that supplies the indexer for Radarr/Sonarr/etc.). - // Its native queue record is the only place the indexer is exposed, so - // persist it here against the download ID — otherwise these imports show - // up in indexer health as "Unknown". - if q.DownloadID != "" && q.Indexer != "" { - w.captureSportarrIndexer(ctx, q.DownloadID, q.Indexer) - } - - // Strategy 1: Immediate cleanup for already imported files - if w.checkGhostByImportHistory(ctx, q.OutputPath, cfg, instance.Name, q.Title) { - idsToRemove = append(idsToRemove, q.ID) - continue - } - - // Fallback: path-gone check with safety guards - if w.isGhostByPathGone(ctx, q.OutputPath, q.ID, cfg, instance.Name, q.Title) { - idsToRemove = append(idsToRemove, q.ID) - continue - } - - // Strategy 2: Graceful cleanup for blocked/failed imports - if q.Status != "completed" || q.TrackedDownloadStatus != "warning" || (q.TrackedDownloadState != "importPending" && q.TrackedDownloadState != "importBlocked") { - continue - } - - // Check if path is within managed directories (import_dir, mount_path, or complete_dir) - if !w.isPathManaged(q.OutputPath, cfg) { - continue - } - - // Check status messages for known issues - shouldCleanup := false - for _, msg := range q.StatusMessages { - allMessages := strings.Join(msg.Messages, " ") - - // Automatic import failure cleanup (configurable). Message-based rules are - // handled separately by CleanupStuckQueue. Match case-insensitively: *arrs - // emit the phrase mid-sentence (e.g. "...automatic import is not possible."). - if cfg.Arrs.CleanupAutomaticImportFailure != nil && *cfg.Arrs.CleanupAutomaticImportFailure && - strings.Contains(strings.ToLower(allMessages), "automatic import is not possible") { - shouldCleanup = true - break - } - } - - key := fmt.Sprintf("%s|%d", instance.Name, q.ID) - if shouldCleanup { - w.firstSeenMu.Lock() - seenTime, exists := w.firstSeen[key] - if !exists { - w.firstSeen[key] = time.Now() - w.firstSeenMu.Unlock() - slog.DebugContext(ctx, "First saw failed import pending item, starting grace period", - "path", q.OutputPath, "title", q.Title, "instance", instance.Name) - continue - } - w.firstSeenMu.Unlock() - - gracePeriod := time.Duration(cfg.Arrs.QueueCleanupGracePeriodMinutes) * time.Minute - if time.Since(seenTime) < gracePeriod { - continue - } - - slog.InfoContext(ctx, "Found failed import pending item after grace period", - "path", q.OutputPath, "title", q.Title, "instance", instance.Name) - idsToRemove = append(idsToRemove, q.ID) - - w.firstSeenMu.Lock() - delete(w.firstSeen, key) - w.firstSeenMu.Unlock() - } else { - w.firstSeenMu.Lock() - delete(w.firstSeen, key) - w.firstSeenMu.Unlock() - } - } - - if len(idsToRemove) > 0 { - for _, id := range idsToRemove { - if err := client.DeleteQueueItem(ctx, id); err != nil { - if strings.Contains(err.Error(), "404") { - slog.DebugContext(ctx, "Queue item already removed from Sportarr", "id", id) - } else { - slog.ErrorContext(ctx, "Failed to delete queue item", - "id", id, "error", err) - } - } - } - slog.InfoContext(ctx, "Cleaned up Sportarr queue items", - "instance", instance.Name, "count", len(idsToRemove)) - } - return nil -} - // checkGhostByImportHistory checks if a queue item has already been imported // by looking up AltMount's import history. Returns true if confirmed ghost // (i.e., the file has been moved to the library). @@ -686,37 +289,3 @@ func (w *Worker) isGhostByPathGone(ctx context.Context, outputPath string, queue "missing_duration", time.Since(seenTime)) return true } - -func (w *Worker) isPathManaged(path string, cfg *config.Config) bool { - if path == "" { - return false - } - - cleanPath := filepath.Clean(path) - - // Check import_dir - if cfg.Import.ImportDir != nil && *cfg.Import.ImportDir != "" { - importDir := filepath.Clean(*cfg.Import.ImportDir) - if strings.HasPrefix(cleanPath, importDir) { - return true - } - } - - // Check mount_path - if cfg.MountPath != "" { - mountPath := filepath.Clean(cfg.MountPath) - if strings.HasPrefix(cleanPath, mountPath) { - return true - } - } - - // Check sabnzbd complete_dir - if cfg.SABnzbd.Enabled != nil && *cfg.SABnzbd.Enabled && cfg.SABnzbd.CompleteDir != "" { - completeDir := filepath.Clean(cfg.SABnzbd.CompleteDir) - if strings.HasPrefix(cleanPath, completeDir) { - return true - } - } - - return false -} diff --git a/internal/config/manager.go b/internal/config/manager.go index 197f46ef3..87599d956 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -446,14 +446,13 @@ type ArrsConfig struct { SportarrInstances []ArrsInstanceConfig `yaml:"sportarr_instances" mapstructure:"sportarr_instances" json:"sportarr_instances"` QueueCleanupEnabled *bool `yaml:"queue_cleanup_enabled" mapstructure:"queue_cleanup_enabled" json:"queue_cleanup_enabled,omitempty"` QueueCleanupIntervalSeconds int `yaml:"queue_cleanup_interval_seconds" mapstructure:"queue_cleanup_interval_seconds" json:"queue_cleanup_interval_seconds,omitempty"` - CleanupAutomaticImportFailure *bool `yaml:"cleanup_automatic_import_failure" mapstructure:"cleanup_automatic_import_failure" json:"cleanup_automatic_import_failure,omitempty"` QueueCleanupGracePeriodMinutes int `yaml:"queue_cleanup_grace_period_minutes" mapstructure:"queue_cleanup_grace_period_minutes" json:"queue_cleanup_grace_period_minutes,omitempty"` // QueueCleanupRules matches an *arr status message for a stuck/failed import and // decides the action (remove / blocklist / blocklist+search). This is the single - // message-rule list for queue cleanup; ghost/empty-folder detection and the - // CleanupAutomaticImportFailure toggle run alongside it. Only items owned by - // AltMount's download client are touched (see issue #523). + // message-rule list for queue cleanup; ghost/empty-folder detection runs alongside + // it in the same pass. Only items owned by AltMount's download client are touched + // (see issue #523). QueueCleanupRules []StuckCleanupRule `yaml:"queue_cleanup_rules,omitempty" mapstructure:"queue_cleanup_rules" json:"queue_cleanup_rules,omitempty"` // Deprecated: the fields below are read from existing config files for one-time @@ -464,6 +463,10 @@ type ArrsConfig struct { StuckCleanupEnabled *bool `yaml:"stuck_cleanup_enabled,omitempty" mapstructure:"stuck_cleanup_enabled" json:"-"` StuckCleanupGracePeriodMinutes int `yaml:"stuck_cleanup_grace_period_minutes,omitempty" mapstructure:"stuck_cleanup_grace_period_minutes" json:"-"` StuckCleanupRules []StuckCleanupRule `yaml:"stuck_cleanup_rules,omitempty" mapstructure:"stuck_cleanup_rules" json:"-"` + // Deprecated: the hardcoded "automatic import is not possible" purge has been + // folded into the unified QueueCleanupRules (see migrateArrsCleanup). Read for + // one-time migration only, then cleared. Do not use in new code. + CleanupAutomaticImportFailure *bool `yaml:"cleanup_automatic_import_failure,omitempty" mapstructure:"cleanup_automatic_import_failure" json:"-"` } // Stuck cleanup actions decide what happens to a matched stuck import. @@ -500,54 +503,87 @@ type StuckCleanupRule struct { func migrateArrsCleanup(config *Config) { a := &config.Arrs - legacyPresent := len(a.StuckCleanupRules) > 0 || + // The legacy split-cleanup model (separate stuck rules / allowlist / enable flag / + // grace period) predated the unified queue_cleanup_rules and coexisted with no + // queue_cleanup_rules at all. When present, rebuild the unified list from it so the + // user's actual settings override the defaults DefaultConfig pre-populated. + legacySplitPresent := len(a.StuckCleanupRules) > 0 || len(a.QueueCleanupAllowlist) > 0 || a.StuckCleanupEnabled != nil || a.StuckCleanupGracePeriodMinutes > 0 - if !legacyPresent { - return - } - - // Rebuild the unified rules from the legacy config: the stuck rules verbatim, - // then allowlist entries as plain "remove" rules. Rule matching is substring-based - // (see matchStuckRule), so skip any allowlist entry already covered by an existing - // rule whose message is a substring of it — e.g. an allowlist "Sample file" is dead - // next to a "Sample" rule, and would just be a confusing duplicate. - rules := append([]StuckCleanupRule(nil), a.StuckCleanupRules...) - for _, m := range a.QueueCleanupAllowlist { - covered := false - for _, r := range rules { - if r.Message == m.Message || (r.Message != "" && strings.Contains(m.Message, r.Message)) { - covered = true - break + if legacySplitPresent { + // Rebuild the unified rules from the legacy config: the stuck rules verbatim, + // then allowlist entries as plain "remove" rules. Rule matching is substring-based + // (see matchStuckRule), so skip any allowlist entry already covered by an existing + // rule whose message is a substring of it — e.g. an allowlist "Sample file" is dead + // next to a "Sample" rule, and would just be a confusing duplicate. + rules := append([]StuckCleanupRule(nil), a.StuckCleanupRules...) + for _, m := range a.QueueCleanupAllowlist { + covered := false + for _, r := range rules { + if r.Message == m.Message || (r.Message != "" && strings.Contains(m.Message, r.Message)) { + covered = true + break + } + } + if !covered { + rules = append(rules, StuckCleanupRule{ + Message: m.Message, + Enabled: m.Enabled, + Action: StuckActionRemove, + }) } } - if !covered { - rules = append(rules, StuckCleanupRule{ - Message: m.Message, - Enabled: m.Enabled, - Action: StuckActionRemove, - }) + a.QueueCleanupRules = rules + + // Enable unified cleanup if only the legacy stuck toggle was on. + if a.QueueCleanupEnabled == nil && a.StuckCleanupEnabled != nil && *a.StuckCleanupEnabled { + enabled := true + a.QueueCleanupEnabled = &enabled } - } - a.QueueCleanupRules = rules - // Enable unified cleanup if only the legacy stuck toggle was on. - if a.QueueCleanupEnabled == nil && a.StuckCleanupEnabled != nil && *a.StuckCleanupEnabled { - enabled := true - a.QueueCleanupEnabled = &enabled - } + // Prefer the legacy stuck grace period when no queue grace period is configured. + if a.QueueCleanupGracePeriodMinutes == 0 && a.StuckCleanupGracePeriodMinutes > 0 { + a.QueueCleanupGracePeriodMinutes = a.StuckCleanupGracePeriodMinutes + } - // Prefer the legacy stuck grace period when no queue grace period is configured. - if a.QueueCleanupGracePeriodMinutes == 0 && a.StuckCleanupGracePeriodMinutes > 0 { - a.QueueCleanupGracePeriodMinutes = a.StuckCleanupGracePeriodMinutes + // Clear legacy fields so SaveConfig no longer emits them. + a.QueueCleanupAllowlist = nil + a.StuckCleanupEnabled = nil + a.StuckCleanupGracePeriodMinutes = 0 + a.StuckCleanupRules = nil + } + + // Fold the legacy "Import Failure Cleanup" toggle (cleanup_automatic_import_failure) + // into the unified rules. Unlike the split-cleanup fields, this toggle coexisted with + // queue_cleanup_rules, so operate on the already-loaded rules (never rebuild them) to + // avoid wiping the user's customizations. When it was on, preserve the prior purge by + // enabling an existing rule that matches "automatic import is not possible" (e.g. the + // seeded default, which loads disabled) or appending one; matching mirrors + // matchStuckRule (a rule's message is a case-insensitive substring of the phrase). + if a.CleanupAutomaticImportFailure != nil { + if *a.CleanupAutomaticImportFailure { + const phrase = "automatic import is not possible" + found := false + for i := range a.QueueCleanupRules { + r := &a.QueueCleanupRules[i] + if r.Message != "" && strings.Contains(phrase, strings.ToLower(r.Message)) { + r.Enabled = true + found = true + break + } + } + if !found { + a.QueueCleanupRules = append(a.QueueCleanupRules, StuckCleanupRule{ + Message: phrase, + Enabled: true, + Action: StuckActionRemove, + }) + } + } + // Clear the legacy flag so SaveConfig no longer emits it. + a.CleanupAutomaticImportFailure = nil } - - // Clear legacy fields so SaveConfig no longer emits them. - a.QueueCleanupAllowlist = nil - a.StuckCleanupEnabled = nil - a.StuckCleanupGracePeriodMinutes = 0 - a.StuckCleanupRules = nil } // ArrsInstanceConfig represents a single arrs instance configuration @@ -1390,7 +1426,6 @@ func DefaultConfig(configDir ...string) *Config { failedItemRetentionHours := 24 // Default: auto-remove failed items after 24 hours historyRetentionDays := 90 // Default: auto-remove import history after 90 days (3 months) isoAnalyzeTimeoutSeconds := 120 // Default: 120s hard cap per ISO analyse (prevents stuck NNTP from stalling import for 9+ minutes) - cleanupAutomaticImportFailure := false metadataBackupEnabled := false failureMaskingEnabled := false repairEnabled := true @@ -1604,7 +1639,6 @@ func DefaultConfig(configDir ...string) *Config { ReadarrInstances: []ArrsInstanceConfig{}, WhisparrInstances: []ArrsInstanceConfig{}, SportarrInstances: []ArrsInstanceConfig{}, - CleanupAutomaticImportFailure: &cleanupAutomaticImportFailure, QueueCleanupGracePeriodMinutes: 5, // Default to 5 minutes stuck before acting // Rule table modeled on wArrden's queue cleanup. Action decides what to do: // blocklist_search (bad release → block + re-search), blocklist (block but @@ -1645,6 +1679,12 @@ func DefaultConfig(configDir ...string) *Config { {Message: "No video files were found in the selected folder", Enabled: true, Action: StuckActionRemove}, {Message: "Could not find file", Enabled: true, Action: StuckActionRemove}, {Message: "Download doesn't contain intermediate path", Enabled: true, Action: StuckActionRemove}, + // Folded from the former "Import Failure Cleanup" toggle (cleanup_automatic_import_failure). + // Seeded disabled to match the toggle's default-off behavior, but discoverable so + // users can switch it on (and pick blocklist/blocklist_search if they prefer). A + // migrated config that had the toggle enabled gets this rule enabled automatically + // (see migrateArrsCleanup). + {Message: "automatic import is not possible", Enabled: false, Action: StuckActionRemove}, }, }, Fuse: FuseConfig{ diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index 1b9295172..c40bce39c 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -321,3 +321,98 @@ func TestMigrateArrsCleanup_NoLegacyNoop(t *testing.T) { assert.Equal(t, rules, cfg.Arrs.QueueCleanupRules) } +func TestMigrateArrsCleanup_AutoFailureFlag_SeedsRemoveRule(t *testing.T) { + enabled := true + cfg := &Config{ + Arrs: ArrsConfig{ + // A modern config: it already has custom rules AND the legacy toggle on, + // with none of the legacy split-cleanup fields. The rules must be preserved + // (not rebuilt/wiped) and the auto-failure rule appended. + QueueCleanupRules: []StuckCleanupRule{ + {Message: "Sample", Enabled: true, Action: StuckActionBlocklistSearch}, + }, + CleanupAutomaticImportFailure: &enabled, + }, + } + + migrateArrsCleanup(cfg) + a := cfg.Arrs + + assert.Equal(t, []StuckCleanupRule{ + {Message: "Sample", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "automatic import is not possible", Enabled: true, Action: StuckActionRemove}, + }, a.QueueCleanupRules) + + // Legacy flag cleared so it drops out of saved YAML. + assert.Nil(t, a.CleanupAutomaticImportFailure) + + // Idempotent: a second pass changes nothing. + before := a.QueueCleanupRules + migrateArrsCleanup(cfg) + assert.Equal(t, before, cfg.Arrs.QueueCleanupRules) + + b, err := yaml.Marshal(cfg.Arrs) + assert.NoError(t, err) + assert.NotContains(t, string(b), "cleanup_automatic_import_failure") +} + +func TestMigrateArrsCleanup_AutoFailureFlag_EnablesExistingRule(t *testing.T) { + enabled := true + cfg := &Config{ + Arrs: ArrsConfig{ + // Mirrors a fresh-install config (the seeded rule loads disabled) whose owner + // had the legacy toggle on: the existing rule is enabled in place, not duplicated. + QueueCleanupRules: []StuckCleanupRule{ + {Message: "automatic import is not possible", Enabled: false, Action: StuckActionRemove}, + }, + CleanupAutomaticImportFailure: &enabled, + }, + } + + migrateArrsCleanup(cfg) + + assert.Equal(t, []StuckCleanupRule{ + {Message: "automatic import is not possible", Enabled: true, Action: StuckActionRemove}, + }, cfg.Arrs.QueueCleanupRules) + assert.Nil(t, cfg.Arrs.CleanupAutomaticImportFailure) +} + +func TestMigrateArrsCleanup_AutoFailureFlag_EnablesSubstringRule(t *testing.T) { + enabled := true + cfg := &Config{ + Arrs: ArrsConfig{ + // A rule whose message is a substring of the phrase already covers it (matching + // is substring-based), so it is enabled rather than a duplicate appended. + QueueCleanupRules: []StuckCleanupRule{ + {Message: "Automatic import", Enabled: false, Action: StuckActionBlocklistSearch}, + }, + CleanupAutomaticImportFailure: &enabled, + }, + } + + migrateArrsCleanup(cfg) + + assert.Equal(t, []StuckCleanupRule{ + {Message: "Automatic import", Enabled: true, Action: StuckActionBlocklistSearch}, + }, cfg.Arrs.QueueCleanupRules) + assert.Nil(t, cfg.Arrs.CleanupAutomaticImportFailure) +} + +func TestMigrateArrsCleanup_AutoFailureFlag_FalseClearsOnly(t *testing.T) { + disabled := false + rules := []StuckCleanupRule{ + {Message: "Sample", Enabled: true, Action: StuckActionBlocklistSearch}, + } + cfg := &Config{ + Arrs: ArrsConfig{ + QueueCleanupRules: rules, + CleanupAutomaticImportFailure: &disabled, + }, + } + + migrateArrsCleanup(cfg) + + // Flag off: no rule seeded, existing rules untouched, flag cleared. + assert.Equal(t, rules, cfg.Arrs.QueueCleanupRules) + assert.Nil(t, cfg.Arrs.CleanupAutomaticImportFailure) +} From a80b7c01b03fc4ca868a0f8f1de4094991c68d4c Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Wed, 3 Jun 2026 02:33:44 +0530 Subject: [PATCH 11/11] refactor(arrs): validate queue-cleanup rule actions; drop dead cleanup scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject unknown queue_cleanup_rules actions in Config.Validate (empty still allowed, treated as "remove" at runtime). Covers both the API-save path (ValidateConfigUpdate) and config load. Remove the unused force parameter and StuckCleanupResult/InstanceCleanupResult types from CleanupStuckQueue and its helpers — the manual-trigger endpoint that used them is gone, so the periodic tick is the only caller. Grace-period bypass is preserved via the gracePeriod <= 0 branch. --- internal/arrs/worker/stuck_cleanup.go | 87 ++++++++++----------------- internal/arrs/worker/worker.go | 6 +- internal/config/manager.go | 12 ++++ internal/config/manager_test.go | 42 +++++++++++++ 4 files changed, 90 insertions(+), 57 deletions(-) diff --git a/internal/arrs/worker/stuck_cleanup.go b/internal/arrs/worker/stuck_cleanup.go index 5a1dca02c..94faaa918 100644 --- a/internal/arrs/worker/stuck_cleanup.go +++ b/internal/arrs/worker/stuck_cleanup.go @@ -14,20 +14,6 @@ import ( "golift.io/starr/sonarr" ) -// StuckCleanupResult summarizes a stuck-import cleanup run across all instances. -type StuckCleanupResult struct { - Instances []InstanceCleanupResult `json:"instances"` - TotalBlocked int `json:"total_blocked"` -} - -// InstanceCleanupResult is the per-instance outcome of a stuck-import cleanup run. -type InstanceCleanupResult struct { - Instance string `json:"instance"` - Type string `json:"type"` - Blocked int `json:"blocked"` - Error string `json:"error,omitempty"` -} - // stuckItem is a normalized view of an *arr queue record across all client types, // holding only the fields the stuck-import detection needs. type stuckItem struct { @@ -44,22 +30,19 @@ type stuckItem struct { // are stuck importing for a known reason, then removes and blocklists them so the // release is not grabbed again and the *arr searches for a replacement. // -// When force is false an item is only acted on after it has been continuously -// observed stuck for the configured grace period (transient errors that the *arr -// resolves on its own are left alone). When force is true the grace period is -// bypassed and everything currently matching is blocklisted immediately. -// -// The automatic periodic run is gated by IsQueueCleanupEnabled at the caller -// (the worker tick); this method itself only requires arrs to be enabled, so the -// manual trigger works regardless of the periodic toggle. -func (w *Worker) CleanupStuckQueue(ctx context.Context, force bool) (*StuckCleanupResult, error) { +// An item is only acted on after it has been continuously observed stuck for the +// configured grace period (transient errors that the *arr resolves on its own are +// left alone); ghost/empty-folder items are removed grace-free. The periodic run is +// gated by IsQueueCleanupEnabled at the caller (the worker tick); this method itself +// only requires arrs to be enabled. +func (w *Worker) CleanupStuckQueue(ctx context.Context) error { cfg := w.configGetter() - result := &StuckCleanupResult{Instances: []InstanceCleanupResult{}} if cfg.Arrs.Enabled == nil || !*cfg.Arrs.Enabled { - return result, nil + return nil } + totalBlocked := 0 for _, instance := range w.instances.GetAllInstances() { if instance == nil || !instance.Enabled { continue @@ -69,36 +52,32 @@ func (w *Worker) CleanupStuckQueue(ctx context.Context, force bool) (*StuckClean var err error switch instance.Type { case "radarr": - blocked, err = w.cleanupStuckRadarr(ctx, instance, cfg, force) + blocked, err = w.cleanupStuckRadarr(ctx, instance, cfg) case "sonarr": - blocked, err = w.cleanupStuckSonarr(ctx, instance, cfg, force, false) + blocked, err = w.cleanupStuckSonarr(ctx, instance, cfg, false) case "whisparr": - blocked, err = w.cleanupStuckSonarr(ctx, instance, cfg, force, true) + blocked, err = w.cleanupStuckSonarr(ctx, instance, cfg, true) case "lidarr": - blocked, err = w.cleanupStuckLidarr(ctx, instance, cfg, force) + blocked, err = w.cleanupStuckLidarr(ctx, instance, cfg) case "readarr": - blocked, err = w.cleanupStuckReadarr(ctx, instance, cfg, force) + blocked, err = w.cleanupStuckReadarr(ctx, instance, cfg) case "sportarr": - blocked, err = w.cleanupStuckSportarr(ctx, instance, cfg, force) + blocked, err = w.cleanupStuckSportarr(ctx, instance, cfg) default: continue } - res := InstanceCleanupResult{Instance: instance.Name, Type: instance.Type, Blocked: blocked} if err != nil { - res.Error = err.Error() slog.WarnContext(ctx, "Failed to clean up stuck imports", "instance", instance.Name, "type", instance.Type, "error", err) } - result.Instances = append(result.Instances, res) - result.TotalBlocked += blocked + totalBlocked += blocked } - if result.TotalBlocked > 0 { - slog.InfoContext(ctx, "Stuck import cleanup acted on releases", - "count", result.TotalBlocked, "force", force) + if totalBlocked > 0 { + slog.InfoContext(ctx, "Stuck import cleanup acted on releases", "count", totalBlocked) } - return result, nil + return nil } // stuckAction is a queue item selected for cleanup plus how to act on it @@ -139,11 +118,11 @@ func stuckRuleFor(item stuckItem, cfg *config.Config) *config.StuckCleanupRule { // selectStuckActions filters AltMount-owned queue items to those that should be // cleaned now, carrying each item's action. Ghost/empty-folder items (already // imported, or source path gone) are removed first, grace-free. The remainder are -// matched against the message rules: with force, all matching items are returned -// immediately; otherwise an item must have been observed stuck for the configured -// grace period. First observations and items the *arr has since resolved are +// matched against the message rules: an item must have been observed stuck for the +// configured grace period before it is acted on (or immediately when no grace period +// is configured). First observations and items the *arr has since resolved are // tracked/cleared via the shared firstSeen map. -func (w *Worker) selectStuckActions(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, items []stuckItem, force bool) []stuckAction { +func (w *Worker) selectStuckActions(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, items []stuckItem) []stuckAction { var actions []stuckAction gracePeriod := time.Duration(cfg.Arrs.QueueCleanupGracePeriodMinutes) * time.Minute @@ -178,7 +157,7 @@ func (w *Worker) selectStuckActions(ctx context.Context, instance *model.ConfigI continue } - if force || gracePeriod <= 0 { + if gracePeriod <= 0 { actions = append(actions, stuckAction{ID: item.ID, Action: rule.Action}) w.firstSeenMu.Lock() delete(w.firstSeen, key) @@ -263,7 +242,7 @@ func flattenStarrMessages(msgs []*starr.StatusMessage) []string { return out } -func (w *Worker) cleanupStuckRadarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool) (int, error) { +func (w *Worker) cleanupStuckRadarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config) (int, error) { client, err := w.clients.GetOrCreateRadarrClient(instance.Name, instance.URL, instance.APIKey) if err != nil { return 0, fmt.Errorf("failed to get Radarr client: %w", err) @@ -286,12 +265,12 @@ func (w *Worker) cleanupStuckRadarr(ctx context.Context, instance *model.ConfigI }) } - actions := w.selectStuckActions(ctx, instance, cfg, items, force) + actions := w.selectStuckActions(ctx, instance, cfg, items) return w.deleteStarrQueue(ctx, instance, actions, client.DeleteQueueContext), nil } // cleanupStuckSonarr handles Sonarr and Whisparr (both use the Sonarr client). -func (w *Worker) cleanupStuckSonarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool, whisparr bool) (int, error) { +func (w *Worker) cleanupStuckSonarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, whisparr bool) (int, error) { var ( client *sonarr.Sonarr err error @@ -322,11 +301,11 @@ func (w *Worker) cleanupStuckSonarr(ctx context.Context, instance *model.ConfigI }) } - actions := w.selectStuckActions(ctx, instance, cfg, items, force) + actions := w.selectStuckActions(ctx, instance, cfg, items) return w.deleteStarrQueue(ctx, instance, actions, client.DeleteQueueContext), nil } -func (w *Worker) cleanupStuckLidarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool) (int, error) { +func (w *Worker) cleanupStuckLidarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config) (int, error) { client, err := w.clients.GetOrCreateLidarrClient(instance.Name, instance.URL, instance.APIKey) if err != nil { return 0, fmt.Errorf("failed to get Lidarr client: %w", err) @@ -350,11 +329,11 @@ func (w *Worker) cleanupStuckLidarr(ctx context.Context, instance *model.ConfigI }) } - actions := w.selectStuckActions(ctx, instance, cfg, items, force) + actions := w.selectStuckActions(ctx, instance, cfg, items) return w.deleteStarrQueue(ctx, instance, actions, client.DeleteQueueContext), nil } -func (w *Worker) cleanupStuckReadarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool) (int, error) { +func (w *Worker) cleanupStuckReadarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config) (int, error) { client, err := w.clients.GetOrCreateReadarrClient(instance.Name, instance.URL, instance.APIKey) if err != nil { return 0, fmt.Errorf("failed to get Readarr client: %w", err) @@ -377,13 +356,13 @@ func (w *Worker) cleanupStuckReadarr(ctx context.Context, instance *model.Config }) } - actions := w.selectStuckActions(ctx, instance, cfg, items, force) + actions := w.selectStuckActions(ctx, instance, cfg, items) return w.deleteStarrQueue(ctx, instance, actions, client.DeleteQueueContext), nil } // cleanupStuckSportarr mirrors the starr path but uses Sportarr's native client, // which is not starr-compatible. -func (w *Worker) cleanupStuckSportarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config, force bool) (int, error) { +func (w *Worker) cleanupStuckSportarr(ctx context.Context, instance *model.ConfigInstance, cfg *config.Config) (int, error) { client, err := w.clients.GetOrCreateSportarrClient(instance.Name, instance.URL, instance.APIKey) if err != nil { return 0, fmt.Errorf("failed to get Sportarr client: %w", err) @@ -423,7 +402,7 @@ func (w *Worker) cleanupStuckSportarr(ctx context.Context, instance *model.Confi }) } - actions := w.selectStuckActions(ctx, instance, cfg, items, force) + actions := w.selectStuckActions(ctx, instance, cfg, items) cleaned := 0 for _, a := range actions { var err error diff --git a/internal/arrs/worker/worker.go b/internal/arrs/worker/worker.go index 0c663d47b..0b924323b 100644 --- a/internal/arrs/worker/worker.go +++ b/internal/arrs/worker/worker.go @@ -136,9 +136,9 @@ func (w *Worker) safeCleanup() { return } // One unified pass per tick covers all six *arr types: ghost/empty-folder removal, - // then the message-rule actions. force=false so items are observed over time and - // only acted on once stuck past the grace period (ghost removal stays grace-free). - if _, err := w.CleanupStuckQueue(w.workerCtx, false); err != nil { + // then the message-rule actions. Items are observed over time and only acted on + // once stuck past the grace period (ghost removal stays grace-free). + if err := w.CleanupStuckQueue(w.workerCtx); err != nil { slog.Error("Queue cleanup failed", "error", err) } } diff --git a/internal/config/manager.go b/internal/config/manager.go index 87599d956..373f6e2c6 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -933,6 +933,18 @@ func (c *Config) Validate() error { c.Fuse.AsyncBufferMaxTotalMB = 0 } + // Validate arrs queue-cleanup rule actions: an explicitly set action must be a + // known value. An empty action is allowed and treated as "remove" at runtime + // (see starrDeleteOpts), so it is not rejected here. + for i, rule := range c.Arrs.QueueCleanupRules { + switch rule.Action { + case "", StuckActionRemove, StuckActionBlocklist, StuckActionBlocklistSearch: + default: + return fmt.Errorf("arrs queue_cleanup_rules[%d]: invalid action %q (must be %q, %q, or %q)", + i, rule.Action, StuckActionRemove, StuckActionBlocklist, StuckActionBlocklistSearch) + } + } + return nil } diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index c40bce39c..c41f91702 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -123,6 +123,48 @@ func TestConfig_Validate_MountPaths(t *testing.T) { } } +func TestConfig_Validate_QueueCleanupRuleAction(t *testing.T) { + // Build an otherwise-valid config carrying a single rule with the given action. + // The action check runs at the end of Validate(), so everything else must pass. + newValidConfig := func(action string) *Config { + return &Config{ + MountType: MountTypeNone, + Metadata: MetadataConfig{RootPath: "/metadata"}, + WebDAV: WebDAVConfig{Port: 8080}, + Streaming: StreamingConfig{MaxPrefetch: 30}, + Import: ImportConfig{ + MaxProcessorWorkers: 2, + QueueProcessingIntervalSeconds: 5, + MaxImportConnections: 5, + MaxDownloadPrefetch: 3, + SegmentSamplePercentage: 1, + ImportStrategy: ImportStrategyNone, + }, + Health: HealthConfig{ + CheckIntervalSeconds: 5, + MaxConnectionsForHealthChecks: 5, + MaxConcurrentJobs: 1, + SegmentSamplePercentage: 5, + }, + Arrs: ArrsConfig{ + QueueCleanupRules: []StuckCleanupRule{ + {Message: "Sample", Enabled: true, Action: action}, + }, + }, + } + } + + // Known actions — and empty, which safely degrades to "remove" at runtime — pass. + for _, action := range []string{"", StuckActionRemove, StuckActionBlocklist, StuckActionBlocklistSearch} { + assert.NoError(t, newValidConfig(action).Validate(), "action %q should be valid", action) + } + + // An unknown action is rejected. + err := newValidConfig("delete_everything").Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid action") +} + func TestConfig_GetWebhookBaseURL(t *testing.T) { tests := []struct { name string