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({ )} + +
+ Automatically clears imports that get stuck for a known reason — runs on a + schedule or on demand from the Queue page. +
+ Match an *arr error message to an action: remove, blocklist, or blocklist + + search. +
{name}
- Comparative efficiency rating across active indexers (%) -
@@ -205,7 +228,7 @@ export function IndexerHealth() { setShowPruneModal(true)} disabled={!hasStats} aria-label="Prune indexer statistics history" @@ -236,7 +259,7 @@ export function IndexerHealth() { - + diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthCard.tsx b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthCard.tsx index 6b3e7dea6..1626ff1fa 100644 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthCard.tsx +++ b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthCard.tsx @@ -34,33 +34,33 @@ export function IndexerHealthCard({ item, onDelete }: IndexerHealthCardProps) { const isGood = item.success_rate >= 75 && item.success_rate < 90; const isPoor = item.success_rate >= 50 && item.success_rate < 75; - const accentColor = isExcellent - ? "border-teal-500/15 hover:border-teal-500/40 hover:shadow-[0_0_15px_rgba(20,184,166,0.1)]" - : isGood - ? "border-emerald-500/15 hover:border-emerald-500/40 hover:shadow-[0_0_15px_rgba(16,185,129,0.1)]" + // Map the four performance tiers onto AltMount's daisyUI theme tokens so the + // colors follow the active theme: success (excellent/good), warning (moderate), + // error (operational/low). + const accentColor = + isExcellent || isGood + ? "border-success/15 hover:border-success/40" : isPoor - ? "border-amber-500/15 hover:border-amber-500/40 hover:shadow-[0_0_15px_rgba(245,158,11,0.1)]" - : "border-slate-500/15 hover:border-slate-500/40 hover:shadow-[0_0_15px_rgba(148,163,184,0.1)]"; + ? "border-warning/15 hover:border-warning/40" + : "border-error/15 hover:border-error/40"; const barSuccessWidth = item.total_imports > 0 ? (item.success_count / item.total_imports) * 100 : 0; const barFailWidth = item.total_imports > 0 ? (item.failed_count / item.total_imports) * 100 : 0; - const topLineGradient = isExcellent - ? "from-teal-500/40 to-teal-600/10" - : isGood - ? "from-emerald-500/40 to-emerald-600/10" + const topLineGradient = + isExcellent || isGood + ? "from-success/40 to-success/10" : isPoor - ? "from-amber-500/40 to-amber-600/10" - : "from-slate-500/40 to-slate-600/10"; + ? "from-warning/40 to-warning/10" + : "from-error/40 to-error/10"; - const statusBadgeColor = isExcellent - ? "bg-teal-500/10 text-teal-400 border-teal-500/20" - : isGood - ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" + const statusBadgeColor = + isExcellent || isGood + ? "bg-success/10 text-success border-success/20" : isPoor - ? "bg-amber-500/10 text-amber-500 border-amber-500/20" - : "bg-slate-500/10 text-slate-400 border-slate-500/20"; + ? "bg-warning/10 text-warning border-warning/20" + : "bg-error/10 text-error border-error/20"; const statusText = isExcellent ? "EXCELLENT" @@ -70,13 +70,8 @@ export function IndexerHealthCard({ item, onDelete }: IndexerHealthCardProps) { ? "MODERATE" : "OPERATIONAL"; - const percentColor = isExcellent - ? "text-teal-600 dark:text-teal-400" - : isGood - ? "text-emerald-600 dark:text-emerald-400" - : isPoor - ? "text-amber-600 dark:text-amber-500" - : "text-slate-600 dark:text-slate-400"; + const percentColor = + isExcellent || isGood ? "text-success" : isPoor ? "text-warning" : "text-error"; return ( onDelete(item.indexer)} aria-label={`Delete statistics for ${item.indexer}`} > @@ -133,7 +128,7 @@ export function IndexerHealthCard({ item, onDelete }: IndexerHealthCardProps) { - {item.success_count} OK - {item.failed_count} FAILED + {item.success_count} OK + {item.failed_count} FAILED {/* Import Pulse Stream */} - + Import Pulse Stream (Last 24) @@ -168,9 +163,9 @@ export function IndexerHealthCard({ item, onDelete }: IndexerHealthCardProps) { (dot, idx) => { const dotColor = dot === "success" - ? "bg-teal-500/80 hover:bg-teal-400 shadow-[0_0_6px_rgba(20,184,166,0.4)]" + ? "bg-success/80 hover:bg-success" : dot === "failed" - ? "bg-rose-500/80 hover:bg-rose-400 shadow-[0_0_6px_rgba(239,68,68,0.4)]" + ? "bg-error/80 hover:bg-error" : "bg-base-200/30 border border-base-200"; const dotTip = dot === "success" @@ -201,7 +196,7 @@ export function IndexerHealthCard({ item, onDelete }: IndexerHealthCardProps) { - + {item.success_count.toLocaleString()} @@ -210,7 +205,7 @@ export function IndexerHealthCard({ item, onDelete }: IndexerHealthCardProps) { - + {item.failed_count.toLocaleString()} diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthFilters.tsx b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthFilters.tsx index cef821ceb..4a66a2972 100644 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthFilters.tsx +++ b/frontend/src/pages/HealthPage/components/IndexerHealth/IndexerHealthFilters.tsx @@ -34,7 +34,7 @@ export function IndexerHealthFilters({ placeholder="Search indexers..." value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} - className="input input-bordered input-sm w-full border-base-300 bg-base-200/50 pl-8 font-medium text-base-content placeholder-base-content/40 focus:border-teal-500/50" + className="input input-bordered input-sm w-full border-base-300 bg-base-200/50 pl-8 font-medium text-base-content placeholder-base-content/40 focus:border-primary/50" aria-label="Search indexers" /> @@ -42,11 +42,7 @@ export function IndexerHealthFilters({ - + Filter @@ -55,21 +51,13 @@ export function IndexerHealthFilters({ let btnClass = "btn-ghost text-base-content/60 hover:text-base-content hover:bg-base-content/5 border-transparent"; if (active) { - if (filter === "excellent") - btnClass = - "bg-teal-500/15 border-teal-500/30 text-teal-400 shadow-[0_0_8px_rgba(20,184,166,0.25)]"; - else if (filter === "good") - btnClass = - "bg-emerald-500/15 border-emerald-500/30 text-emerald-400 shadow-[0_0_8px_rgba(16,185,129,0.25)]"; + if (filter === "excellent" || filter === "good") + btnClass = "bg-success/15 border-success/30 text-success"; else if (filter === "moderate") - btnClass = - "bg-amber-500/15 border-amber-500/30 text-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.25)]"; + btnClass = "bg-warning/15 border-warning/30 text-warning"; else if (filter === "operational") - btnClass = - "bg-slate-500/15 border-slate-500/30 text-slate-400 shadow-[0_0_8px_rgba(148,163,184,0.25)]"; - else - btnClass = - "bg-primary/15 border-primary/30 text-primary shadow-[0_0_8px_rgba(59,130,246,0.25)]"; + btnClass = "bg-error/15 border-error/30 text-error"; + else btnClass = "bg-primary/15 border-primary/30 text-primary"; } return ( ); })} - + {/* 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 && ( - <> - - - - {summary.best.indexer} - - Highest efficiency rating · {summary.best.success_rate.toFixed(1)}% - - - - - - - - {summary.worst.indexer} - - - Needs telemetry inspection · {summary.worst.success_rate.toFixed(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" > - + Prune Statistics @@ -117,7 +117,7 @@ export function PruneStatsModal({ isPending, onClose, onPrune }: PruneStatsModal diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/types.ts b/frontend/src/pages/HealthPage/components/IndexerHealth/types.ts index 8a67793c3..f41238c4f 100644 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/types.ts +++ b/frontend/src/pages/HealthPage/components/IndexerHealth/types.ts @@ -12,8 +12,6 @@ export interface IndexerSummary { totalSuccess: number; totalFailed: number; overallRate: number; - best: IndexerStat; - worst: IndexerStat; } export type SortKey = "health" | "total" | "name"; From 9007b1b594c132fb7255ce838508b967ceb5d148 Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:24:14 +0530 Subject: [PATCH 04/11] feat(health): theme-consistent colors for provider health Replace hardcoded tailwind palette colors and rgba glows with daisyUI theme tokens across the provider health UI, matching the indexer health page: - charts: palette + tooltip/cursor sourced from theme vars (var(--color-*)) instead of fixed hex; fixed stale daisyUI v4 hsl(var(--bc/--b1)) tooltip - provider table badges/dots: emerald/amber/rose -> success/warning/error - quota vials + slate text: themed via base/success/warning/error tokens (index.css vial liquid/glass), drop hardcoded glow shadows --- frontend/bun.lock | 6 - frontend/package.json | 4 +- frontend/src/api/client.ts | 62 - .../src/components/charts/HealthChart.tsx | 87 -- frontend/src/components/charts/QueueChart.tsx | 49 - .../components/config/RCloneConfigSection.tsx | 1019 ----------------- .../components/queue/ManualScanSection.tsx | 209 ---- .../components/system/ActiveStreamsCard.tsx | 205 ---- .../components/system/RecentCompletions.tsx | 38 - frontend/src/components/ui/BytesDisplay.tsx | 3 - frontend/src/components/ui/ErrorAlert.tsx | 22 - frontend/src/components/ui/KeyValueEditor.tsx | 102 -- frontend/src/components/ui/LoadingSpinner.tsx | 12 - frontend/src/contexts/ModalContext.tsx | 2 +- frontend/src/hooks/useConfig.ts | 16 - frontend/src/index.css | 42 +- frontend/src/pages/ConfigurationPage.tsx | 9 - .../HealthPage/components/IndexerHealth.tsx | 2 - .../ProviderHealth/ProviderHealth.tsx | 30 +- .../ProviderHealth/ProviderQuota.tsx | 18 +- .../components/ProviderHealth/chartShared.tsx | 41 +- frontend/src/pages/QueuePage.tsx | 11 +- frontend/src/services/webdavClient.ts | 26 - frontend/src/types/api.ts | 8 - frontend/src/types/config.ts | 147 --- internal/api/auth_updater.go | 29 - internal/api/response.go | 10 - internal/api/server.go | 18 - internal/api/types.go | 19 - internal/config/manager.go | 13 - .../database/{testing.go => testing_test.go} | 0 internal/encryption/rclone/utils.go | 28 - internal/fuse/server.go | 15 - internal/health/checker.go | 6 - internal/health/library_sync.go | 2 +- internal/health/scheduler.go | 2 +- internal/health/worker.go | 14 - internal/httpclient/client.go | 7 - internal/httpclient/proxy.go | 7 +- internal/importer/interfaces.go | 66 -- internal/library/library.go | 221 ---- internal/metadata/reader.go | 96 -- internal/metadata/service.go | 88 -- internal/nzbfilesystem/constants.go | 40 +- .../nzbfilesystem/metadata_remote_file.go | 6 - internal/nzbfilesystem/nzb_filesystem.go | 25 - internal/slogutil/data.go | 18 - internal/utils/copy.go | 34 - internal/utils/path.go | 41 - internal/utils/range.go | 22 - internal/webdav/adapter.go | 5 - internal/webdav/auth_updater.go | 35 - pkg/rclonecli/mount.go | 17 - 53 files changed, 94 insertions(+), 2960 deletions(-) delete mode 100644 frontend/src/components/charts/HealthChart.tsx delete mode 100644 frontend/src/components/charts/QueueChart.tsx delete mode 100644 frontend/src/components/config/RCloneConfigSection.tsx delete mode 100644 frontend/src/components/queue/ManualScanSection.tsx delete mode 100644 frontend/src/components/system/ActiveStreamsCard.tsx delete mode 100644 frontend/src/components/system/RecentCompletions.tsx delete mode 100644 frontend/src/components/ui/KeyValueEditor.tsx delete mode 100644 frontend/src/pages/HealthPage/components/IndexerHealth.tsx delete mode 100644 internal/api/auth_updater.go rename internal/database/{testing.go => testing_test.go} (100%) delete mode 100644 internal/library/library.go delete mode 100644 internal/utils/copy.go diff --git a/frontend/bun.lock b/frontend/bun.lock index 27e206357..5dd2e0814 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -15,11 +15,9 @@ "lucide-react": "^0.540.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-hook-form": "^7.62.0", "react-router-dom": "^7.8.1", "recharts": "^3.1.2", "webdav": "^5.8.0", - "zod": "^4.0.17", }, "devDependencies": { "@biomejs/biome": "^2.2.2", @@ -945,8 +943,6 @@ "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="], - "react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="], - "react-is": ["react-is@19.1.1", "", {}, "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" } }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], @@ -1181,8 +1177,6 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], diff --git a/frontend/package.json b/frontend/package.json index 86604fb54..4be04a56a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,11 +22,9 @@ "lucide-react": "^0.540.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-hook-form": "^7.62.0", "react-router-dom": "^7.8.1", "recharts": "^3.1.2", - "webdav": "^5.8.0", - "zod": "^4.0.17" + "webdav": "^5.8.0" }, "devDependencies": { "@biomejs/biome": "^2.2.2", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f85c7c904..2ea43a4fa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -24,7 +24,6 @@ import type { QueueHistoricalStatsResponse, QueueItem, QueueStats, - SABnzbdAddResponse, ScanStatusResponse, SystemBrowseResponse, UploadNZBLnkResponse, @@ -248,10 +247,6 @@ export class APIClient { return this.requestWithMeta(`/queue${query ? `?${query}` : ""}`); } - async getQueueItem(id: number) { - return this.request(`/queue/${id}`); - } - async deleteQueueItem(id: number) { return this.request(`/queue/${id}`, { method: "DELETE" }); } @@ -385,10 +380,6 @@ export class APIClient { return this.requestWithMeta(`/health${query ? `?${query}` : ""}`); } - async getHealthItem(id: string) { - return this.request(`/health/${encodeURIComponent(id)}`); - } - async deleteHealthItem(id: number, options?: { deleteMeta?: boolean; deleteSymlink?: boolean }) { const searchParams = new URLSearchParams(); if (options?.deleteMeta) searchParams.set("delete_meta", "true"); @@ -444,13 +435,6 @@ export class APIClient { }); } - async retryHealthItem(id: string, resetStatus?: boolean) { - return this.request(`/health/${encodeURIComponent(id)}/retry`, { - method: "POST", - body: JSON.stringify({ reset_status: resetStatus }), - }); - } - async repairHealthItem(id: number, resetRepairRetryCount?: boolean) { return this.request(`/health/${id}/repair`, { method: "POST", @@ -458,15 +442,6 @@ export class APIClient { }); } - async getCorruptedFiles(params?: { limit?: number; offset?: number }) { - const searchParams = new URLSearchParams(); - if (params?.limit) searchParams.set("limit", params.limit.toString()); - if (params?.offset) searchParams.set("offset", params.offset.toString()); - - const query = searchParams.toString(); - return this.request(`/health/corrupted${query ? `?${query}` : ""}`); - } - async getHealthStats() { return this.request("/health/stats"); } @@ -686,10 +661,6 @@ export class APIClient { }); } - async getArrsHealth() { - return this.request>("/arrs/health"); - } - async registerArrsWebhooks() { return this.request<{ message: string }>("/arrs/webhook/register", { method: "POST", @@ -742,13 +713,6 @@ export class APIClient { return this.request("/config"); } - async updateConfig(config: ConfigUpdateRequest) { - return this.request("/config", { - method: "PUT", - body: JSON.stringify(config), - }); - } - async updateConfigSection(section: ConfigSection, config: ConfigUpdateRequest) { return this.request(`/config/${section}`, { method: "PATCH", @@ -941,32 +905,6 @@ export class APIClient { }); } - // SABnzbd file upload endpoint - async uploadNzbFile(file: File, apiKey: string): Promise { - const formData = new FormData(); - formData.append("nzbfile", file); - - const url = `/sabnzbd?mode=addfile&apikey=${encodeURIComponent(apiKey)}`; - - const response = await fetch(url, { - method: "POST", - body: formData, - credentials: "include", // Include cookies for Safari compatibility - }); - - if (!response.ok) { - throw new APIError(response.status, `Upload failed: ${response.statusText}`, ""); - } - - const data = await response.json(); - if (!data.status) { - const err = data as APIError; - throw new APIError(response.status, err.message || "Upload failed", err.details || ""); - } - - return data; - } - // Native upload endpoint using JWT authentication async uploadToQueue( file: File, diff --git a/frontend/src/components/charts/HealthChart.tsx b/frontend/src/components/charts/HealthChart.tsx deleted file mode 100644 index a11eaea55..000000000 --- a/frontend/src/components/charts/HealthChart.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { - Bar, - BarChart, - CartesianGrid, - Cell, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { useHealthStats } from "../../hooks/useApi"; -import { LoadingSpinner } from "../ui/LoadingSpinner"; - -export function HealthChart() { - const { data: stats, isLoading, error } = useHealthStats(); - - if (isLoading) { - return ( - - - - ); - } - - if (error || !stats) { - return ( - - Failed to load health statistics - - ); - } - - // Include all categories to maintain consistent x-axis, even if zero - const data = [ - { name: "Healthy", value: stats.healthy, color: "#10b981" }, // success - { name: "Checking", value: stats.checking, color: "#3b82f6" }, // info - { name: "Pending", value: stats.pending, color: "#f59e0b" }, // warning - { name: "Repairing", value: stats.repair_triggered, color: "#8b5cf6" }, // purple - { name: "Corrupted", value: stats.corrupted, color: "#ef4444" }, // error - ]; - - // Check if all values are zero - if (data.every((item) => item.value === 0)) { - return ( - - No files tracked - - ); - } - - return ( - - - - - - - - {data.map((entry, index) => ( - - ))} - - - - ); -} diff --git a/frontend/src/components/charts/QueueChart.tsx b/frontend/src/components/charts/QueueChart.tsx deleted file mode 100644 index 5a9dfdf2a..000000000 --- a/frontend/src/components/charts/QueueChart.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; -import { useQueueStats } from "../../hooks/useApi"; -import { LoadingSpinner } from "../ui/LoadingSpinner"; - -export function QueueChart() { - const { data: stats, isLoading, error } = useQueueStats(); - - if (isLoading) { - return ( - - - - ); - } - - if (error || !stats) { - return ( - - Failed to load queue statistics - - ); - } - - const data = [ - { name: "Queued", value: stats.total_queued, fill: "#f59e0b" }, - { name: "Processing", value: stats.total_processing, fill: "#3b82f6" }, - { name: "Completed", value: stats.total_completed, fill: "#10b981" }, - { name: "Failed", value: stats.total_failed, fill: "#ef4444" }, - ]; - - return ( - - - - - - - - - - ); -} diff --git a/frontend/src/components/config/RCloneConfigSection.tsx b/frontend/src/components/config/RCloneConfigSection.tsx deleted file mode 100644 index 373995627..000000000 --- a/frontend/src/components/config/RCloneConfigSection.tsx +++ /dev/null @@ -1,1019 +0,0 @@ -import { Eye, EyeOff, HardDrive, Play, Save, Square, TestTube } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { useConfirm } from "../../contexts/ModalContext"; -import { useToast } from "../../contexts/ToastContext"; -import type { - ConfigResponse, - MountStatus, - RCloneMountFormData, - RCloneRCFormData, -} from "../../types/config"; -import { KeyValueEditor } from "../ui/KeyValueEditor"; - -interface RCloneConfigSectionProps { - config: ConfigResponse; - onUpdate?: ( - section: string, - data: - | Partial - | Partial - | { mount_path: string } - | { rclone: RCloneMountFormData; mount_path: string }, - ) => Promise; - isReadOnly?: boolean; - isUpdating?: boolean; -} - -export function RCloneConfigSection({ - config, - onUpdate, - isReadOnly = false, - isUpdating = false, -}: RCloneConfigSectionProps) { - const [formData, setFormData] = useState({ - rc_enabled: config.rclone.rc_enabled, - rc_url: config.rclone.rc_url, - vfs_name: config.rclone.vfs_name || "altmount", - rc_port: config.rclone.rc_port, - rc_user: config.rclone.rc_user, - rc_pass: "", - rc_options: config.rclone.rc_options, - }); - - const [mountFormData, setMountFormData] = useState({ - mount_enabled: config.rclone.mount_enabled || false, - mount_options: config.rclone.mount_options || {}, - - // Mount-Specific Settings - allow_other: config.rclone.allow_other || true, - allow_non_empty: config.rclone.allow_non_empty || true, - read_only: config.rclone.read_only || false, - timeout: config.rclone.timeout || "10m", - syslog: config.rclone.syslog || true, - - // System and filesystem options - log_level: config.rclone.log_level || "INFO", - uid: config.rclone.uid || 1000, - gid: config.rclone.gid || 1000, - umask: config.rclone.umask || "002", - buffer_size: config.rclone.buffer_size || "32M", - attr_timeout: config.rclone.attr_timeout || "1s", - transfers: config.rclone.transfers || 4, - - // VFS Cache Settings - cache_dir: config.rclone.cache_dir || "", - vfs_cache_mode: config.rclone.vfs_cache_mode || "full", - vfs_cache_max_size: config.rclone.vfs_cache_max_size || "50G", - vfs_cache_max_age: config.rclone.vfs_cache_max_age || "504h", - vfs_cache_poll_interval: config.rclone.vfs_cache_poll_interval || "1m", - vfs_read_chunk_size: config.rclone.vfs_read_chunk_size || "32M", - vfs_read_chunk_size_limit: config.rclone.vfs_read_chunk_size_limit || "2G", - vfs_read_ahead: config.rclone.vfs_read_ahead || "128M", - dir_cache_time: config.rclone.dir_cache_time || "10m", - vfs_cache_min_free_space: config.rclone.vfs_cache_min_free_space || "1G", - vfs_disk_space_total: config.rclone.vfs_disk_space_total || "1G", - vfs_read_chunk_streams: config.rclone.vfs_read_chunk_streams || 4, - - // Advanced Settings - no_mod_time: config.rclone.no_mod_time || false, - no_checksum: config.rclone.no_checksum || false, - async_read: config.rclone.async_read || true, - vfs_fast_fingerprint: config.rclone.vfs_fast_fingerprint || false, - use_mmap: config.rclone.use_mmap || false, - links: config.rclone.links || false, - }); - - // Separate state for mount path since it's a root-level config - const [mountPath, setMountPath] = useState(config.mount_path || "/mnt/remotes/altmount"); - - const [mountStatus, setMountStatus] = useState(null); - const [hasChanges, setHasChanges] = useState(false); - const [hasMountChanges, setHasMountChanges] = useState(false); - const [hasMountPathChanges, setHasMountPathChanges] = useState(false); - const [showRCPassword, setShowRCPassword] = useState(false); - const [isTestingConnection, setIsTestingConnection] = useState(false); - const [testResult, setTestResult] = useState<{ - success: boolean; - message: string; - } | null>(null); - const [isMountLoading, setIsMountLoading] = useState(false); - const [isMountToggleSaving, setIsMountToggleSaving] = useState(false); - const [isRCToggleSaving, setIsRCToggleSaving] = useState(false); - const { showToast } = useToast(); - const { confirmAction } = useConfirm(); - - // Sync form data when config changes from external sources (reload) - useEffect(() => { - const newFormData = { - rc_enabled: config.rclone.rc_enabled, - rc_url: config.rclone.rc_url, - vfs_name: config.rclone.vfs_name || "altmount", - rc_port: config.rclone.rc_port, - rc_user: config.rclone.rc_user, - rc_pass: "", - rc_options: config.rclone.rc_options, - }; - setFormData(newFormData); - setHasChanges(false); - - const newMountFormData = { - mount_enabled: config.rclone.mount_enabled || false, - mount_options: config.rclone.mount_options || {}, - - // Mount-Specific Settings - allow_other: config.rclone.allow_other || true, - allow_non_empty: config.rclone.allow_non_empty || true, - read_only: config.rclone.read_only || false, - timeout: config.rclone.timeout || "10m", - syslog: config.rclone.syslog || true, - - // System and filesystem options - log_level: config.rclone.log_level || "INFO", - uid: config.rclone.uid || 1000, - gid: config.rclone.gid || 1000, - umask: config.rclone.umask || "002", - buffer_size: config.rclone.buffer_size || "32M", - attr_timeout: config.rclone.attr_timeout || "1s", - transfers: config.rclone.transfers || 4, - - // VFS Cache Settings - cache_dir: config.rclone.cache_dir || "", - vfs_cache_mode: config.rclone.vfs_cache_mode || "full", - vfs_cache_max_size: config.rclone.vfs_cache_max_size || "50G", - vfs_cache_max_age: config.rclone.vfs_cache_max_age || "504h", - vfs_cache_poll_interval: config.rclone.vfs_cache_poll_interval || "1m", - vfs_read_chunk_size: config.rclone.vfs_read_chunk_size || "32M", - vfs_read_chunk_size_limit: config.rclone.vfs_read_chunk_size_limit || "2G", - vfs_read_ahead: config.rclone.vfs_read_ahead || "128M", - dir_cache_time: config.rclone.dir_cache_time || "10m", - vfs_cache_min_free_space: config.rclone.vfs_cache_min_free_space || "1G", - vfs_disk_space_total: config.rclone.vfs_disk_space_total || "1G", - vfs_read_chunk_streams: config.rclone.vfs_read_chunk_streams || 4, - - // Advanced Settings - no_mod_time: config.rclone.no_mod_time || false, - no_checksum: config.rclone.no_checksum || false, - async_read: config.rclone.async_read || true, - vfs_fast_fingerprint: config.rclone.vfs_fast_fingerprint || false, - use_mmap: config.rclone.use_mmap || false, - links: config.rclone.links || false, - }; - setMountFormData(newMountFormData); - setHasMountChanges(false); - - setMountPath(config.mount_path || "/mnt/remotes/altmount"); - setHasMountPathChanges(false); - }, [config]); - - const fetchMountStatus = useCallback(async () => { - try { - const response = await fetch("/api/mount/status"); - if (response.ok) { - const data = await response.json(); - setMountStatus(data.data); - } - } catch (error) { - console.error("Failed to fetch mount status:", error); - } - }, []); - - useEffect(() => { - fetchMountStatus(); - const interval = setInterval(fetchMountStatus, 5000); - return () => clearInterval(interval); - }, [fetchMountStatus]); - - const handleInputChange = ( - field: keyof RCloneRCFormData, - value: string | number | boolean | Record, - ) => { - const newFormData = { ...formData, [field]: value }; - setFormData(newFormData); - - // Compare with initial config to see if there are changes - const initialFormData = { - rc_enabled: config.rclone.rc_enabled, - rc_url: config.rclone.rc_url, - vfs_name: config.rclone.vfs_name || "altmount", - rc_port: config.rclone.rc_port, - rc_user: config.rclone.rc_user, - rc_pass: "", - rc_options: config.rclone.rc_options, - }; - setHasChanges(JSON.stringify(newFormData) !== JSON.stringify(initialFormData)); - }; - - const handleMountInputChange = ( - field: keyof RCloneMountFormData, - value: string | number | boolean | Record, - ) => { - const newMountFormData = { ...mountFormData, [field]: value }; - setMountFormData(newMountFormData); - - // Compare with initial config to see if there are changes - const initialMountFormData = { - mount_enabled: config.rclone.mount_enabled || false, - mount_options: config.rclone.mount_options || {}, - - // Mount-Specific Settings - allow_other: config.rclone.allow_other || true, - allow_non_empty: config.rclone.allow_non_empty || true, - read_only: config.rclone.read_only || false, - timeout: config.rclone.timeout || "10m", - syslog: config.rclone.syslog || true, - - // System and filesystem options - log_level: config.rclone.log_level || "INFO", - uid: config.rclone.uid || 1000, - gid: config.rclone.gid || 1000, - umask: config.rclone.umask || "002", - buffer_size: config.rclone.buffer_size || "32M", - attr_timeout: config.rclone.attr_timeout || "1s", - transfers: config.rclone.transfers || 4, - - // VFS Cache Settings - cache_dir: config.rclone.cache_dir || "", - vfs_cache_mode: config.rclone.vfs_cache_mode || "full", - vfs_cache_max_size: config.rclone.vfs_cache_max_size || "50G", - vfs_cache_max_age: config.rclone.vfs_cache_max_age || "504h", - vfs_cache_poll_interval: config.rclone.vfs_cache_poll_interval || "1m", - vfs_read_chunk_size: config.rclone.vfs_read_chunk_size || "32M", - vfs_read_chunk_size_limit: config.rclone.vfs_read_chunk_size_limit || "2G", - vfs_read_ahead: config.rclone.vfs_read_ahead || "128M", - dir_cache_time: config.rclone.dir_cache_time || "10m", - vfs_cache_min_free_space: config.rclone.vfs_cache_min_free_space || "1G", - vfs_disk_space_total: config.rclone.vfs_disk_space_total || "1G", - vfs_read_chunk_streams: config.rclone.vfs_read_chunk_streams || 4, - - // Advanced Settings - no_mod_time: config.rclone.no_mod_time || false, - no_checksum: config.rclone.no_checksum || false, - async_read: config.rclone.async_read || true, - vfs_fast_fingerprint: config.rclone.vfs_fast_fingerprint || false, - use_mmap: config.rclone.use_mmap || false, - links: config.rclone.links || false, - }; - setHasMountChanges(JSON.stringify(newMountFormData) !== JSON.stringify(initialMountFormData)); - }; - - const handleMountPathChange = (value: string) => { - setMountPath(value); - setHasMountPathChanges(value !== config.mount_path); - }; - - const handleSave = async () => { - if (onUpdate && hasChanges) { - const saveDelta: Partial = { ...formData }; - // Don't send empty password if it hasn't changed - if (saveDelta.rc_pass === "") { - delete saveDelta.rc_pass; - } - await onUpdate("rclone", saveDelta); - setHasChanges(false); - } - }; - - const handleSaveMount = async () => { - if (onUpdate && (hasMountChanges || hasMountPathChanges)) { - // We need to send both together if they changed - await onUpdate("rclone", { - rclone: mountFormData, - mount_path: mountPath, - }); - setHasMountChanges(false); - setHasMountPathChanges(false); - } - }; - - const handleRCEnabledChange = async (enabled: boolean) => { - if (onUpdate) { - setIsRCToggleSaving(true); - try { - await onUpdate("rclone", { rc_enabled: enabled }); - } finally { - setIsRCToggleSaving(false); - } - } - }; - - const handleMountEnabledChange = async (enabled: boolean) => { - if (onUpdate) { - setIsMountToggleSaving(true); - try { - await onUpdate("rclone", { mount_enabled: enabled }); - } finally { - setIsMountToggleSaving(false); - } - } - }; - - const handleTestConnection = async () => { - setIsTestingConnection(true); - setTestResult(null); - try { - const response = await fetch("/api/mount/test-rc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - const data = await response.json(); - setTestResult({ - success: data.success, - message: data.success - ? "Connection successful!" - : data.error?.message || "Connection failed", - }); - } catch (_error) { - setTestResult({ - success: false, - message: "Failed to connect to RC server", - }); - } finally { - setIsTestingConnection(false); - } - }; - - const handleStartMount = async () => { - const confirmed = await confirmAction( - "Start RClone Mount", - `This will attempt to mount the WebDAV filesystem at ${mountPath}. Continue?`, - ); - if (!confirmed) return; - - setIsMountLoading(true); - try { - const response = await fetch("/api/mount/start", { method: "POST" }); - if (response.ok) { - showToast({ - type: "success", - title: "Mount Started", - message: "RClone mount initiated successfully", - }); - fetchMountStatus(); - } else { - const errorData = await response.json(); - showToast({ - type: "error", - title: "Mount Failed", - message: errorData.error?.message || "Failed to start mount", - }); - } - } catch (_error) { - showToast({ - type: "error", - title: "Error", - message: "Failed to communicate with API", - }); - } finally { - setIsMountLoading(false); - } - }; - - const handleStopMount = async () => { - const confirmed = await confirmAction( - "Stop RClone Mount", - "This will unmount the WebDAV filesystem. Any applications accessing it may experience errors. Continue?", - { type: "warning", confirmText: "Stop Mount" }, - ); - if (!confirmed) return; - - setIsMountLoading(true); - try { - const response = await fetch("/api/mount/stop", { method: "POST" }); - if (response.ok) { - showToast({ - type: "info", - title: "Mount Stopped", - message: "RClone mount stopped successfully", - }); - fetchMountStatus(); - } else { - const errorData = await response.json(); - showToast({ - type: "error", - title: "Stop Failed", - message: errorData.error?.message || "Failed to stop mount", - }); - } - } catch (_error) { - showToast({ - type: "error", - title: "Error", - message: "Failed to communicate with API", - }); - } finally { - setIsMountLoading(false); - } - }; - - return ( - - - RClone Filesystem - - Manage the virtual mount and Remote Control (RC) interface. - - - - - {/* Mount Configuration Section */} - - - - - Mount Configuration - - - - - - Enable Internal Mount - - - Let AltMount manage and mount the virtual filesystem automatically - {isMountToggleSaving && ( - - )} - - handleMountEnabledChange(e.target.checked)} - /> - - - {isMountToggleSaving - ? "Saving..." - : "Highly recommended for all-in-one Docker setups"} - - - - {mountFormData.mount_enabled && ( - <> - - - - - Mount Point Path - handleMountPathChange(e.target.value)} - placeholder="/mnt/remotes/altmount" - /> - - Absolute path where the filesystem will be mounted. - - - - - Mount Log Level - handleMountInputChange("log_level", e.target.value)} - > - DEBUG (Verbose) - INFO (Standard) - NOTICE (Alerts) - ERROR (Critical) - - Verbosity of RClone mount logs. - - - - - - UID - - handleMountInputChange("uid", Number.parseInt(e.target.value, 10) || 1000) - } - placeholder="1000" - /> - User ID for files. - - - - GID - - handleMountInputChange("gid", Number.parseInt(e.target.value, 10) || 1000) - } - placeholder="1000" - /> - Group ID for files. - - - - Umask - handleMountInputChange("umask", e.target.value)} - placeholder="002" - /> - File permission mask. - - - - - Security & Flags - - - Allow Other - - Enable shared access - handleMountInputChange("allow_other", e.target.checked)} - /> - - - - - Allow Non-Empty - - Mount over files - - handleMountInputChange("allow_non_empty", e.target.checked) - } - /> - - - - - Read Only - - Disable writing - handleMountInputChange("read_only", e.target.checked)} - /> - - - - - - - VFS Cache Settings - - - Cache Mode - handleMountInputChange("vfs_cache_mode", e.target.value)} - > - off (No cache) - minimal (Metadata only) - writes (Only modified files) - full (Read & Write cache) - - Determines how much data RClone caches locally. - - - - Cache Directory - handleMountInputChange("cache_dir", e.target.value)} - placeholder="/config/cache" - /> - - Path for cached data (defaults to config/cache). - - - - - - - Max Cache Size - handleMountInputChange("vfs_cache_max_size", e.target.value)} - placeholder="50G" - /> - Maximum cache size (e.g., 50G, 1T). - - - - Cache Max Age - handleMountInputChange("vfs_cache_max_age", e.target.value)} - placeholder="504h" - /> - Maximum cache age (e.g., 504h, 7d). - - - - - - Cache Poll Interval - - handleMountInputChange("vfs_cache_poll_interval", e.target.value) - } - placeholder="1m" - /> - - Interval to poll for remote changes (e.g., 1m, 5s). - - - - - Read Ahead - handleMountInputChange("vfs_read_ahead", e.target.value)} - placeholder="128M" - /> - Read ahead size (e.g., 128M, 256M). - - - - - - Performance Settings - - - Read Chunk Size - - handleMountInputChange("vfs_read_chunk_size", e.target.value) - } - placeholder="32M" - /> - Initial read chunk size (e.g., 32M, 64M). - - - - Read Chunk Size Limit - - handleMountInputChange("vfs_read_chunk_size_limit", e.target.value) - } - placeholder="2G" - /> - Maximum read chunk size (e.g., 2G, 4G). - - - - - - Directory Cache Time - handleMountInputChange("dir_cache_time", e.target.value)} - placeholder="10m" - /> - Directory cache time (e.g., 10m, 1h). - - - - Transfers - - handleMountInputChange( - "transfers", - Number.parseInt(e.target.value, 10) || 4, - ) - } - min="1" - max="32" - /> - Number of parallel transfers (1-32). - - - - - - Advanced Flags - - - Async Read - - Enable async read operations - handleMountInputChange("async_read", e.target.checked)} - /> - - - - - No Mod Time - - Don't write mod time - handleMountInputChange("no_mod_time", e.target.checked)} - /> - - - - - - {/* Custom Mount Options */} - - Custom Mount Options - - Arbitrary flags to pass to the rclone mount command. (e.g.,{" "} - no-modtime: true) - - handleMountInputChange("mount_options", val)} - keyPlaceholder="Flag (e.g. no-modtime)" - valuePlaceholder="Value (e.g. true)" - /> - - - {/* Mount Status & Actions */} - - - {mountStatus && ( - - - - - {mountStatus.mounted ? "Mounted" : "Not Mounted"} - - {mountStatus.mounted && mountStatus.mount_point && ( - Mount point: {mountStatus.mount_point} - )} - {mountStatus.error && {mountStatus.error}} - - - {mountStatus.mounted ? ( - - {isMountLoading ? ( - - ) : ( - - )} - Stop Mount - - ) : ( - - {isMountLoading ? ( - - ) : ( - - )} - Start Mount - - )} - - - )} - - {!isReadOnly && ( - - - {isUpdating ? ( - - ) : ( - - )} - Save Mount Changes - - - )} - > - )} - - - {/* RC Configuration Section */} - - - - - Remote Control (RC) - - - - - - Enable RC Connection - - - Enable connection for cache refresh notifications - {mountFormData.mount_enabled && ( - Managed by mount - )} - - handleRCEnabledChange(e.target.checked)} - /> - - - - {(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"} - /> - setShowRCPassword(!showRCPassword)} - > - {showRCPassword ? ( - - ) : ( - - )} - - - - - - {/* 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 && ( - - - {isTestingConnection && } - Test Connection - - - {isUpdating && } - Save RC Changes - - - )} - > - )} - - - - {/* 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 && ( - - - Start Scan - - )} - - {(isScanning || isCanceling) && ( - - - {isCanceling ? "Canceling..." : "Cancel"} - - )} - - - - {/* 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 && ( - - - - Try Again - - - )} - - - ); -} 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 && ( - handleRemove(key)} - > - - - )} - - ))} - - - {!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 - - )} - @@ -163,9 +163,7 @@ function ConnectionPoolGrid({ used, max }: { used: number; max: number }) { ))} @@ -335,7 +333,7 @@ export function ProviderHealth() { 0 ? "animate-pulse text-primary shadow-[0_0_12px_rgba(59,130,246,0.3)]" : "opacity-45"}`} + className={`h-8 w-8 ${data.download_speed_bytes_per_sec > 0 ? "animate-pulse text-primary" : "opacity-45"}`} /> {/* Active wave line on bottom of the card */} @@ -410,7 +408,7 @@ export function ProviderHealth() { {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 && ( handleReset(provider.id)} disabled={resettingId === provider.id} title="Reset Quota" @@ -88,7 +88,7 @@ export function ProviderQuota() { {/* Physical Cylinder/Vial with Tick Marks */} {/* Left Tick Marks */} - + 100% 75% 50% @@ -124,16 +124,16 @@ export function ProviderQuota() { {/* Numeric Percentage display rotated */} - + {remainingPercent}% - + Left @@ -142,7 +142,7 @@ export function ProviderQuota() { {/* Numerical stats at the bottom */} - + {formatBytes(used)} / {formatBytes(total)} @@ -155,7 +155,7 @@ export function ProviderQuota() { Resets {formatRelativeTime(provider.quota_reset_at)} ) : ( - + No schedule reset )} diff --git a/frontend/src/pages/HealthPage/components/ProviderHealth/chartShared.tsx b/frontend/src/pages/HealthPage/components/ProviderHealth/chartShared.tsx index c1b106d04..7bd8ee4ab 100644 --- a/frontend/src/pages/HealthPage/components/ProviderHealth/chartShared.tsx +++ b/frontend/src/pages/HealthPage/components/ProviderHealth/chartShared.tsx @@ -16,14 +16,16 @@ import { YAxis, } from "recharts"; -export const CHART_COLORS = [ - "#3b82f6", - "#10b981", - "#f59e0b", - "#ef4444", - "#8b5cf6", - "#ec4899", - "#06b6d4", +// Multi-series palette sourced from the active daisyUI theme so chart colors +// follow the selected theme instead of fixed hex values. +const CHART_COLORS = [ + "var(--color-primary)", + "var(--color-success)", + "var(--color-warning)", + "var(--color-error)", + "var(--color-secondary)", + "var(--color-accent)", + "var(--color-info)", ]; export type ChartDatum = Record; @@ -33,7 +35,7 @@ export interface TimeRangeTab { value: number; } -export interface TooltipPayloadItem { +interface TooltipPayloadItem { value: number; dataKey: string; stroke: string; @@ -47,7 +49,7 @@ interface CustomTooltipProps { totalClassName: string; } -export function CustomTooltip({ +function CustomTooltip({ active, payload, label, @@ -89,7 +91,7 @@ export function CustomTooltip({ * Keeps an active/inactive toggle map in sync with the available providers, * defaulting newly seen providers to active. */ -export function useActiveProviders(providers: string[]) { +function useActiveProviders(providers: string[]) { const [activeProviders, setActiveProviders] = useState>({}); useEffect(() => { @@ -125,7 +127,7 @@ interface TimeRangeTabsProps { activeClassName: string; } -export function TimeRangeTabs({ tabs, value, onChange, activeClassName }: TimeRangeTabsProps) { +function TimeRangeTabs({ tabs, value, onChange, activeClassName }: TimeRangeTabsProps) { return ( {tabs.map((tab) => ( @@ -272,7 +274,11 @@ export function ProviderAreaChart({ totalClassName={tooltipTotalClassName} /> } - cursor={{ stroke: "rgba(255,255,255,0.08)", strokeWidth: 1 }} + cursor={{ + stroke: "var(--color-base-content)", + strokeOpacity: 0.1, + strokeWidth: 1, + }} /> { @@ -293,7 +299,8 @@ export function ProviderAreaChart({ formatter={(value, entry) => ( formatValue(value)} contentStyle={{ borderRadius: "12px", - border: "1px solid hsl(var(--bc) / 0.1)", - backgroundColor: "hsl(var(--b1) / 0.95)", + border: + "1px solid color-mix(in oklch, var(--color-base-content) 10%, transparent)", + backgroundColor: "color-mix(in oklch, var(--color-base-100) 95%, transparent)", + color: "var(--color-base-content)", fontSize: "11px", backdropFilter: "blur(8px)", boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.3)", diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 176879f29..0d9e61282 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -26,7 +26,7 @@ import { XCircle, XOctagon, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { ImportMethods } from "../components/queue/ImportMethods"; import { QueueItemCard } from "../components/queue/QueueItemCard"; import { ErrorAlert } from "../components/ui/ErrorAlert"; @@ -285,7 +285,7 @@ export function QueuePage() { if (confirmed) await clearPending.mutateAsync(""); }; - const handleAddTestFile = async (size: "100MB" | "1GB" | "10GB") => { + const handleAddTestFile = async (size: "100MB" | "1GB") => { try { await addTestQueueItem.mutateAsync(size); } catch (error) { @@ -396,13 +396,6 @@ export function QueuePage() { queueData && queueData.length > 0 && queueData.every((item) => selectedItems.has(item.id)); const isIndeterminate = queueData && selectedItems.size > 0 && !isAllSelected; - useEffect(() => { - setPage(0); - }, []); - useEffect(() => { - clearSelection(); - }, [clearSelection]); - if (error) { return ( diff --git a/frontend/src/services/webdavClient.ts b/frontend/src/services/webdavClient.ts index c78e756de..7d4398c4d 100644 --- a/frontend/src/services/webdavClient.ts +++ b/frontend/src/services/webdavClient.ts @@ -77,10 +77,6 @@ export class WebDAVClient { this.client = createClient("/webdav", clientOptions); } - isConnected(): boolean { - return this.client !== null; - } - async listDirectory(path = "/", showCorrupted = false): Promise { if (!this.client) { throw new Error("WebDAV client not connected"); @@ -167,28 +163,6 @@ export class WebDAVClient { } } - async getFileInfo(path: string): Promise { - if (!this.client) { - throw new Error("WebDAV client not connected"); - } - - try { - const stat = (await this.client.stat(path)) as FileStat; - return { - filename: stat.filename, - basename: stat.basename, - lastmod: stat.lastmod, - size: stat.size || 0, - type: stat.type as "file" | "directory", - etag: stat.etag ?? undefined, - mime: stat.mime, - }; - } catch (error) { - console.error("Failed to get file info:", error); - throw this.parseError(error, "get file info", path); - } - } - async deleteFile(path: string): Promise { if (!this.client) { throw new Error("WebDAV client not connected"); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cb283fe63..edf107897 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -223,14 +223,6 @@ export interface HealthStats { checking: number; } -export interface HealthRetryRequest { - reset_status?: boolean; -} - -export interface HealthRepairRequest { - reset_repair_retry_count?: boolean; -} - export interface HealthCleanupRequest { older_than?: string; status?: HealthStatus; diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index f67e1c8e1..e012cf496 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -536,89 +536,6 @@ export interface WebDAVFormData { host?: string; } -export interface ImportFormData { - max_processor_workers: number; - queue_processing_interval_seconds: number; // Interval in seconds for queue processing - max_download_prefetch: number; - read_timeout_seconds: number; - import_strategy: ImportStrategy; - import_dir: string; - watch_dir?: string; - watch_interval_seconds?: number; -} - -export interface MetadataFormData { - root_path: string; - delete_source_nzb_on_removal?: boolean; - backup: MetadataBackupConfig; -} - -export interface StreamingFormData { - max_prefetch: number; - failure_masking: FailureMaskingConfig; -} - -export interface RCloneFormData { - password: string; - rc_enabled: boolean; - rc_url: string; - vfs_name: string; - rc_port: number; - rc_user: string; - rc_pass: string; - rc_options: Record; - mount_enabled: boolean; - mount_options: Record; - - // Mount-Specific Settings - allow_other: boolean; - allow_non_empty: boolean; - read_only: boolean; - timeout: string; - syslog: boolean; - - // System and filesystem options - log_level: string; - uid: number; - gid: number; - umask: string; - buffer_size: string; - attr_timeout: string; - transfers: number; - - // VFS Cache Settings - cache_dir: string; - vfs_cache_mode: string; - vfs_cache_poll_interval: string; - vfs_read_chunk_size: string; - vfs_read_chunk_size_limit: string; - vfs_cache_max_size: string; - vfs_cache_max_age: string; - vfs_read_ahead: string; - dir_cache_time: string; - vfs_cache_min_free_space: string; - vfs_disk_space_total: string; - vfs_read_chunk_streams: number; - - // Advanced Settings - no_mod_time: boolean; - no_checksum: boolean; - async_read: boolean; - vfs_fast_fingerprint: boolean; - use_mmap: boolean; - links: boolean; -} - -export interface RCloneRCFormData { - rc_enabled: boolean; - rc_url: string; - vfs_name: string; - rc_port: number; - rc_user: string; - rc_pass: string; - rc_options: Record; -} - export interface RCloneMountFormData { mount_enabled: boolean; mount_options: Record; @@ -698,21 +615,9 @@ export interface LogFormData { compress: boolean; } -export interface SABnzbdFormData { - enabled: boolean; - complete_dir: string; - categories: SABnzbdCategory[]; - history_retention_minutes: number; - fallback_host: string; - fallback_api_key: string; -} - // Arrs configuration types export type ArrsType = "radarr" | "sonarr" | "lidarr" | "readarr" | "whisparr" | "sportarr"; -// Sync status types -export type SyncStatus = "idle" | "running" | "cancelling" | "completed" | "failed"; - export interface ArrsInstanceConfig { name: string; url: string; @@ -722,21 +627,6 @@ export interface ArrsInstanceConfig { sync_interval_hours: number; } -// Database-backed arrs instance (includes real ID from database) -export interface ArrsInstance { - id: number; - name: string; - type: ArrsType; - url: string; - api_key: string; - category?: string; - enabled: boolean; - sync_interval_hours: number; - last_sync_at?: string; - created_at: string; - updated_at: string; -} - export interface IgnoredMessage { message: string; enabled: boolean; @@ -770,43 +660,6 @@ export interface StuckCleanupRule { action: StuckCleanupAction; } -// Sync status and progress types -export interface SyncProgress { - instance_id: number; - status: SyncStatus; - started_at: string; - processed_count: number; - error_count: number; - total_items?: number; - current_batch: string; -} - -export interface SyncResult { - instance_id: number; - status: SyncStatus; - started_at: string; - completed_at: string; - processed_count: number; - error_count: number; - error_message?: string; -} - -export interface ArrsFormData { - enabled: boolean; - max_workers: number; - webhook_base_url?: string; - radarr_instances: ArrsInstanceConfig[]; - sonarr_instances: ArrsInstanceConfig[]; - lidarr_instances: ArrsInstanceConfig[]; - readarr_instances: ArrsInstanceConfig[]; - whisparr_instances: ArrsInstanceConfig[]; - sportarr_instances: ArrsInstanceConfig[]; - queue_cleanup_enabled?: boolean; - queue_cleanup_interval_seconds?: number; - queue_cleanup_grace_period_minutes?: number; - cleanup_automatic_import_failure?: boolean; -} - // Prowlarr indexer configuration (nested inside StremioConfig) export interface ProwlarrConfig { enabled: boolean; diff --git a/internal/api/auth_updater.go b/internal/api/auth_updater.go deleted file mode 100644 index 2cac58295..000000000 --- a/internal/api/auth_updater.go +++ /dev/null @@ -1,29 +0,0 @@ -package api - -import "sync" - -// AuthUpdater provides methods to update API authentication -type AuthUpdater struct { - server *Server - mutex sync.RWMutex -} - -// NewAuthUpdater creates a new API auth updater -func NewAuthUpdater(server *Server) *AuthUpdater { - return &AuthUpdater{ - server: server, - } -} - -// UpdateAuth updates API authentication credentials (OAuth only) -func (u *AuthUpdater) UpdateAuth(username, password string) error { - u.mutex.Lock() - defer u.mutex.Unlock() - - // Basic authentication has been removed - OAuth flow handles authentication - // This method is kept for interface compatibility but does nothing - _ = username - _ = password - - return nil -} diff --git a/internal/api/response.go b/internal/api/response.go index ae8f46286..234ce5678 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -119,13 +119,3 @@ func RespondServiceUnavailable(c *fiber.Ctx, message, details string) error { c.Set("Retry-After", "10") return RespondError(c, fiber.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", message, details) } - -// Helper function to check for admin privileges and respond with error if not admin. -// Returns true if user is admin, false otherwise (and sends error response). -func RequireAdminPrivileges(c *fiber.Ctx, user interface{ IsAdminUser() bool }) bool { - if user == nil || !user.IsAdminUser() { - RespondForbidden(c, "Admin privileges required", "This endpoint requires admin access") - return false - } - return true -} diff --git a/internal/api/server.go b/internal/api/server.go index c9e731d4f..619fed721 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -26,7 +26,6 @@ import ( "github.com/javi11/altmount/internal/rclone" "github.com/javi11/altmount/internal/updater" "github.com/javi11/altmount/internal/version" - "github.com/javi11/altmount/pkg/rclonecli" ) // Config represents API server configuration @@ -57,7 +56,6 @@ type Server struct { importerService *importer.Service poolManager pool.Manager arrsService *arrs.Service - rcloneClient rclonecli.RcloneRcClient mountService *rclone.MountService startTime time.Time progressBroadcaster *progress.ProgressBroadcaster @@ -135,12 +133,6 @@ func (s *Server) SetHealthWorker(healthWorker *health.HealthWorker) { s.healthWorker = healthWorker } -// SetUpdater overrides the binary updater used for self-update operations. -// Primarily intended for tests that need to substitute a fake implementation. -func (s *Server) SetUpdater(u updater.Updater) { - s.updater = u -} - // SetLibrarySyncWorker sets the library sync worker reference for the server func (s *Server) SetLibrarySyncWorker(librarySyncWorker *health.LibrarySyncWorker) { s.librarySyncWorker = librarySyncWorker @@ -166,16 +158,6 @@ func (s *Server) IsReady() bool { return s.ready.Load() } -// SetRcloneClient sets the rclone client reference for the server -func (s *Server) SetRcloneClient(rcloneClient rclonecli.RcloneRcClient) { - s.rcloneClient = rcloneClient -} - -// GetProgressBroadcaster returns the progress broadcaster for use by the importer service -func (s *Server) GetProgressBroadcaster() *progress.ProgressBroadcaster { - return s.progressBroadcaster -} - // SetupFiberRoutes configures API routes directly on the Fiber app func (s *Server) SetupRoutes(app *fiber.App) { app.Use("/sabnzbd", s.handleSABnzbd) diff --git a/internal/api/types.go b/internal/api/types.go index 92318d10a..becc11c46 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -490,13 +490,6 @@ type QueueItemResponse struct { StoragePath *string `json:"storage_path,omitempty"` // Internal FUSE mount path (populated after completion) } -// QueueListRequest represents request parameters for listing queue items -type QueueListRequest struct { - Status *database.QueueStatus `json:"status"` - Since *time.Time `json:"since"` - Pagination -} - // QueueStatsResponse represents queue statistics in API responses type QueueStatsResponse struct { TotalQueued int `json:"total_queued"` @@ -571,13 +564,6 @@ type HealthItemResponse struct { IsMasked bool `json:"is_masked"` } -// HealthListRequest represents request parameters for listing health records -type HealthListRequest struct { - Status *database.HealthStatus `json:"status"` - Since *time.Time `json:"since"` - Pagination -} - // HealthStatsResponse represents health statistics in API responses type HealthStatsResponse struct { Total int `json:"total"` @@ -588,11 +574,6 @@ type HealthStatsResponse struct { Checking int `json:"checking"` } -// HealthRetryRequest represents request to retry a corrupted file -type HealthRetryRequest struct { - ResetRetryCount bool `json:"reset_retry_count,omitempty"` -} - // HealthRepairRequest represents request to trigger repair for a corrupted file type HealthRepairRequest struct { ResetRepairRetryCount bool `json:"reset_repair_retry_count,omitempty"` diff --git a/internal/config/manager.go b/internal/config/manager.go index ee63400a2..70c6a4bdc 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -1,7 +1,6 @@ package config import ( - "crypto/sha256" "crypto/tls" "fmt" "log/slog" @@ -376,13 +375,6 @@ type HealthConfig struct { Repair RepairConfig `yaml:"repair" mapstructure:"repair" json:"repair"` } -// GenerateProviderID creates a unique ID based on host, port, and username -func GenerateProviderID(host string, port int, username string) string { - input := fmt.Sprintf("%s:%d@%s", host, port, username) - hash := sha256.Sum256([]byte(input)) - return fmt.Sprintf("%x", hash)[:8] // First 8 characters for readability -} - // Path validation functions have been moved to internal/utils/path.go // ProviderConfig represents a single NNTP provider configuration @@ -1733,8 +1725,3 @@ func LoadConfig(configFile string) (*Config, error) { return config, nil } - -// GetConfigFilePath returns the configuration file path used by viper -func GetConfigFilePath() string { - return viper.ConfigFileUsed() -} diff --git a/internal/database/testing.go b/internal/database/testing_test.go similarity index 100% rename from internal/database/testing.go rename to internal/database/testing_test.go diff --git a/internal/encryption/rclone/utils.go b/internal/encryption/rclone/utils.go index 8c69398d4..302e68e35 100644 --- a/internal/encryption/rclone/utils.go +++ b/internal/encryption/rclone/utils.go @@ -1,33 +1,5 @@ package rclone -import ( - "errors" - "fmt" - "strings" -) - -const splitter = ":salt:" - -var ErrInvalidPassword = errors.New("invalid password") - -func ExtractPasswordAndSalt(password string) (string, string) { - p := strings.Split(password, splitter) - if len(p) != 2 { - return "", "" - } - - return p[0], p[1] -} - -func PasswordFromPasswordAndSalt(password, salt string) string { - return fmt.Sprintf( - "%s%s%s", - password, - splitter, - salt, - ) -} - func DecryptedSize(size int64) (int64, error) { size -= int64(fileHeaderSize) if size < 0 { diff --git a/internal/fuse/server.go b/internal/fuse/server.go index b2e700442..c7ec3f4c0 100644 --- a/internal/fuse/server.go +++ b/internal/fuse/server.go @@ -157,18 +157,3 @@ func (s *Server) ValidateMount() (bool, error) { } } -// RefreshDirectory invalidates the kernel cache for a named directory entry. -// Only works with backends that implement backend.Refresher (e.g. hanwen). -func (s *Server) RefreshDirectory(name string) { - if r, ok := s.be.(backend.Refresher); ok { - r.RefreshDirectory(name) - } -} - -// BackendType returns the active backend type. -func (s *Server) BackendType() backend.Type { - if s.be != nil { - return s.be.Type() - } - return s.backendType -} diff --git a/internal/health/checker.go b/internal/health/checker.go index c19864d12..a3c627279 100644 --- a/internal/health/checker.go +++ b/internal/health/checker.go @@ -34,7 +34,6 @@ type HealthEvent struct { Error error Details *string Timestamp time.Time - RetryCount int SourceNzb *string } @@ -253,11 +252,6 @@ func (hc *HealthChecker) notifyRcloneVFS(filePath string, event HealthEvent) { }() } -// GetHealthStats returns current health statistics -func (hc *HealthChecker) GetHealthStats(ctx context.Context) (map[database.HealthStatus]int, error) { - return hc.healthRepo.GetHealthStats(ctx) -} - type metadataSegmentLoader struct { segments []*metapb.SegmentData } diff --git a/internal/health/library_sync.go b/internal/health/library_sync.go index 3a7bd0eba..dc9543418 100644 --- a/internal/health/library_sync.go +++ b/internal/health/library_sync.go @@ -1134,7 +1134,7 @@ func (lsw *LibrarySyncWorker) processMetadataForSync( releaseDateAsTime := time.Unix(releaseDate, 0) // Calculate initial check time - scheduledCheckAt := calculateInitialCheck(releaseDateAsTime) + scheduledCheckAt := calculateInitialCheck() cfg := lsw.configGetter() diff --git a/internal/health/scheduler.go b/internal/health/scheduler.go index eb138841e..55129016a 100644 --- a/internal/health/scheduler.go +++ b/internal/health/scheduler.go @@ -15,7 +15,7 @@ const ( ) // calculateInitialCheck calculates the first check time for a newly discovered file -func calculateInitialCheck(releaseDate time.Time) time.Time { +func calculateInitialCheck() time.Time { // Spread initial checks over the next 24 hours to avoid thundering herd on bulk imports jitterMinutes := rand.Intn(1440) return time.Now().UTC().Add(time.Duration(jitterMinutes) * time.Minute) diff --git a/internal/health/worker.go b/internal/health/worker.go index e0ebf4f63..d79973f70 100644 --- a/internal/health/worker.go +++ b/internal/health/worker.go @@ -202,13 +202,6 @@ func (hw *HealthWorker) IsRunning() bool { return hw.running } -// GetStatus returns the current worker status -func (hw *HealthWorker) GetStatus() WorkerStatus { - hw.mu.RLock() - defer hw.mu.RUnlock() - return hw.status -} - // GetStats returns current worker statistics func (hw *HealthWorker) GetStats() WorkerStats { hw.statsMu.RLock() @@ -254,13 +247,6 @@ func (hw *HealthWorker) IsCheckActive(filePath string) bool { return exists } -// IsCycleRunning returns whether a health check cycle is currently running -func (hw *HealthWorker) IsCycleRunning() bool { - hw.mu.RLock() - defer hw.mu.RUnlock() - return hw.cycleRunning -} - // run is the main worker loop func (hw *HealthWorker) run(ctx context.Context) { ticker := time.NewTicker(hw.getCheckInterval()) diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go index 079f8a872..2352d9c6c 100644 --- a/internal/httpclient/client.go +++ b/internal/httpclient/client.go @@ -31,13 +31,6 @@ func WithTimeout(d time.Duration) Option { } } -// WithTransport sets a custom transport. -func WithTransport(t *http.Transport) Option { - return func(o *Options) { - o.Transport = t - } -} - // New creates a new HTTP client with the given options. // If no timeout is specified, DefaultTimeout (30s) is used. func New(opts ...Option) *http.Client { diff --git a/internal/httpclient/proxy.go b/internal/httpclient/proxy.go index 84c9da657..9a9ea4ce4 100644 --- a/internal/httpclient/proxy.go +++ b/internal/httpclient/proxy.go @@ -27,14 +27,11 @@ func buildProxyFunc(httpProxy, httpsProxy, noProxy string) func(*http.Request) ( // WithProxyConfig returns an Option that installs a proxy-aware // *http.Transport derived from the supplied values. Pass empty strings to -// disable proxying. If a Transport was already set by an earlier option, the -// proxy function is layered on top of a clone of that transport. +// disable proxying. func WithProxyConfig(httpProxy, httpsProxy, noProxy string) Option { return func(o *Options) { var base *http.Transport - if o.Transport != nil { - base = o.Transport.Clone() - } else if dt, ok := http.DefaultTransport.(*http.Transport); ok { + if dt, ok := http.DefaultTransport.(*http.Transport); ok { base = dt.Clone() } else { base = &http.Transport{} diff --git a/internal/importer/interfaces.go b/internal/importer/interfaces.go index 858f51941..cc305ec37 100644 --- a/internal/importer/interfaces.go +++ b/internal/importer/interfaces.go @@ -54,66 +54,6 @@ type QueueOperations interface { GetQueueStats(ctx context.Context) (*database.QueueStats, error) } -// SymlinkCreator handles symlink creation for imported files -type SymlinkCreator interface { - // CreateSymlinks creates symlinks for an imported item - CreateSymlinks(item *database.ImportQueueItem, resultingPath string) error -} - -// StrmGenerator handles STRM file generation -type StrmGenerator interface { - // CreateStrmFiles creates STRM files for an imported item - CreateStrmFiles(item *database.ImportQueueItem, resultingPath string) error -} - -// VFSNotifier handles rclone VFS cache notifications -type VFSNotifier interface { - // NotifyVFS notifies rclone VFS about file changes - NotifyVFS(ctx context.Context, resultingPath string, async bool) - // RefreshMountPathIfNeeded refreshes the mount path cache if required - RefreshMountPathIfNeeded(ctx context.Context, resultingPath string, itemID int64) -} - -// HealthScheduler handles health check scheduling for imported files -type HealthScheduler interface { - // ScheduleHealthCheck schedules a health check for an imported file - ScheduleHealthCheck(ctx context.Context, filePath string, sourceNzb string, priority database.HealthPriority) error -} - -// ARRNotifier handles notifications to ARR applications (Sonarr/Radarr) -type ARRNotifier interface { - // NotifyARR notifies ARR applications about imported content - NotifyARR(ctx context.Context, item *database.ImportQueueItem, resultingPath string) error -} - -// SABnzbdFallback handles fallback to external SABnzbd for failed imports -type SABnzbdFallback interface { - // AttemptFallback tries to send a failed import to external SABnzbd - AttemptFallback(ctx context.Context, item *database.ImportQueueItem) error -} - -// IDMetadataLinker handles NzbDav ID metadata linking -type IDMetadataLinker interface { - // HandleIDMetadataLinks creates ID-based metadata links - HandleIDMetadataLinks(item *database.ImportQueueItem, resultingPath string) -} - -// PostProcessor coordinates all post-import processing steps -type PostProcessor interface { - SymlinkCreator - StrmGenerator - VFSNotifier - HealthScheduler - ARRNotifier - SABnzbdFallback - IDMetadataLinker - - // HandleSuccess performs all post-processing for successful imports - HandleSuccess(ctx context.Context, item *database.ImportQueueItem, resultingPath string) error - // HandleFailure performs all cleanup for failed imports - HandleFailure(ctx context.Context, item *database.ImportQueueItem, processingErr error) error -} - // ImportService is the main interface combining all importer capabilities type ImportService interface { QueueManager @@ -133,12 +73,6 @@ type ImportService interface { RegenerateMetadata(ctx context.Context, mountRelativePath string) error } -// FileSizeCalculator calculates file sizes for different file types -type FileSizeCalculator interface { - // CalculateFileSizeOnly calculates the size of a file without full processing - CalculateFileSizeOnly(filePath string) (int64, error) -} - // HistoryRecorder records successful import events in persistent storage type HistoryRecorder interface { // AddImportHistory records a successful file import diff --git a/internal/library/library.go b/internal/library/library.go deleted file mode 100644 index 4b4b1cbd0..000000000 --- a/internal/library/library.go +++ /dev/null @@ -1,221 +0,0 @@ -package library - -import ( - "context" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/javi11/altmount/internal/config" -) - -// LibraryItemFinder handles finding library items (symlinks and STRM files) with caching -type LibraryItemFinder struct { - cache map[string]string // virtual path -> library item path - cacheMu sync.RWMutex -} - -// NewLibraryItemFinder creates a new library item finder -func NewLibraryItemFinder() *LibraryItemFinder { - return &LibraryItemFinder{ - cache: make(map[string]string), - } -} - -// FindLibraryItem searches for a library item (symlink or .strm file) based on import strategy -// It checks the cache first, and if not found, performs a recursive search through the library directory -// Returns the library item path if found, empty string otherwise -func (lif *LibraryItemFinder) FindLibraryItem(ctx context.Context, filePath string, cfg *config.Config) (string, error) { - // If library_dir is not configured, return empty - if cfg.Health.LibraryDir == nil || *cfg.Health.LibraryDir == "" { - return "", nil - } - - libraryDir := *cfg.Health.LibraryDir - mountDir := cfg.MountPath - - // Check cache first using virtual path as key - lif.cacheMu.RLock() - if cachedPath, ok := lif.cache[filePath]; ok { - lif.cacheMu.RUnlock() - - // Verify the cached item still exists - if _, err := os.Lstat(cachedPath); err == nil { - slog.DebugContext(ctx, "Found library item in cache", - "virtual_path", filePath, - "library_path", cachedPath) - return cachedPath, nil - } - - // Item no longer exists, remove from cache and continue searching - lif.cacheMu.Lock() - delete(lif.cache, filePath) - lif.cacheMu.Unlock() - slog.DebugContext(ctx, "Cached library item no longer exists, removed from cache", - "virtual_path", filePath, - "cached_path", cachedPath) - // Fall through to directory search - } else { - lif.cacheMu.RUnlock() - } - - // Get import strategy for selection logic - strategy := cfg.Import.ImportStrategy - - slog.InfoContext(ctx, "Searching for library item", - "virtual_path", filePath, - "library_dir", libraryDir, - "strategy", strategy) - - // Search for both symlinks and STRM files in a single pass - foundSymlink, foundStrm := lif.findBothLibraryItems(ctx, filePath, libraryDir, mountDir) - - // Use strategy to decide which one to return if both exist - var foundItem string - if foundSymlink != "" && foundStrm != "" { - switch strategy { - case config.ImportStrategySYMLINK: - foundItem = foundSymlink - if foundItem != "" { - slog.InfoContext(ctx, "Using symlink (strategy: SYMLINK)", - "virtual_path", filePath, - "library_path", foundItem) - } - - case config.ImportStrategySTRM: - foundItem = foundStrm - if foundItem != "" { - slog.InfoContext(ctx, "Using STRM file (strategy: STRM)", - "virtual_path", filePath, - "library_path", foundItem) - } - - case config.ImportStrategyNone: - // No library items should be used - slog.DebugContext(ctx, "Import strategy is NONE, not using any library items found") - return "", nil - - default: - slog.WarnContext(ctx, "Unknown import strategy", "strategy", strategy) - return "", nil - } - } else if foundSymlink != "" { - foundItem = foundSymlink - } else if foundStrm != "" { - foundItem = foundStrm - } - - if foundItem != "" { - // Cache the successful finding - lif.cacheMu.Lock() - lif.cache[filePath] = foundItem - lif.cacheMu.Unlock() - return foundItem, nil - } - - slog.InfoContext(ctx, "No matching library item found", - "virtual_path", filePath, - "strategy", strategy, - "found_symlink", foundSymlink != "", - "found_strm", foundStrm != "") - return "", nil -} - -// findBothLibraryItems searches for both symlinks and .strm files in a single directory walk -// Returns both paths (empty strings if not found), allowing caller to decide which to use based on strategy -func (lif *LibraryItemFinder) findBothLibraryItems(ctx context.Context, filePath, libraryDir, mountDir string) (symlinkPath, strmPath string) { - mountFilePath := filepath.Join(mountDir, filePath) - - // Expected STRM file path (for quick comparison) - expectedStrmPath := filepath.Join(libraryDir, filePath+".strm") - expectedStrmPathNormalized := filepath.Join(libraryDir, strings.ReplaceAll(filePath, "\\", "/")+".strm") - - // Walk the library directory recursively once, checking for both types - err := filepath.WalkDir(libraryDir, func(path string, d os.DirEntry, err error) error { - // Check context cancellation - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - if err != nil { - return nil // Continue walking despite errors - } - - // Check if it's a symlink - if d.Type()&os.ModeSymlink != 0 { - // Read the symlink target - target, err := os.Readlink(path) - if err != nil { - slog.WarnContext(ctx, "Failed to read symlink", "path", path, "error", err) - return nil - } - - // Make target absolute if it's relative - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(path), target) - } - - // Clean the paths for comparison - cleanTarget := filepath.Clean(target) - cleanMountPath := filepath.Clean(mountFilePath) - - // Check if this symlink points to our mount path - if cleanTarget == cleanMountPath { - symlinkPath = path - slog.DebugContext(ctx, "Found symlink for virtual path", - "virtual_path", filePath, - "symlink_path", path) - } - } - - // Check if it's a .strm file matching our virtual path - if !d.IsDir() && strings.HasSuffix(d.Name(), ".strm") { - cleanPath := filepath.Clean(path) - cleanExpected := filepath.Clean(expectedStrmPath) - cleanExpectedNorm := filepath.Clean(expectedStrmPathNormalized) - - if cleanPath == cleanExpected || cleanPath == cleanExpectedNorm { - strmPath = path - slog.DebugContext(ctx, "Found .strm file for virtual path", - "virtual_path", filePath, - "strm_path", path) - } - } - - // Early exit if both found - if symlinkPath != "" && strmPath != "" { - return filepath.SkipAll - } - - return nil - }) - - if err != nil && err != filepath.SkipAll { - slog.ErrorContext(ctx, "Error during library item search", "error", err) - } - - return symlinkPath, strmPath -} - -// FindLibrarySymlink searches for a symlink in the library directory that points to the given file path -// Deprecated: Use FindLibraryItem instead, which handles both symlinks and STRM files based on import strategy -// Returns the library symlink path if found, empty string otherwise -func (lif *LibraryItemFinder) FindLibrarySymlink(ctx context.Context, filePath string, cfg *config.Config) (string, error) { - // For backward compatibility, only search for symlinks - // Check if library_dir is configured - if cfg.Health.LibraryDir == nil || *cfg.Health.LibraryDir == "" { - return "", nil - } - - // Check if using symlink strategy - if cfg.Import.ImportStrategy != config.ImportStrategySYMLINK { - return "", nil - } - - // Use FindLibraryItem which will search for symlinks - return lif.FindLibraryItem(ctx, filePath, cfg) -} diff --git a/internal/metadata/reader.go b/internal/metadata/reader.go index 695ec60b1..5161a6ce3 100644 --- a/internal/metadata/reader.go +++ b/internal/metadata/reader.go @@ -1,11 +1,6 @@ package metadata import ( - "fmt" - "io/fs" - "os" - "path/filepath" - metapb "github.com/javi11/altmount/internal/metadata/proto" ) @@ -21,102 +16,11 @@ func NewMetadataReader(service *MetadataService) *MetadataReader { } } -// ListDirectoryContents lists all contents in a virtual directory path. -// Returns real directories as fs.FileInfo. File metadata is no longer returned; -// callers should use MetadataService.ReadFileMetadataLite for lightweight listing -// or ReadFileMetadata when full segment data is needed. -func (mr *MetadataReader) ListDirectoryContents(virtualPath string) ([]fs.FileInfo, error) { - virtualPath = filepath.Clean(virtualPath) - if virtualPath == "." { - virtualPath = "/" - } - - metadataDir := mr.service.GetMetadataDirectoryPath(virtualPath) - - entries, err := os.ReadDir(metadataDir) - if err != nil { - if os.IsNotExist(err) { - return []fs.FileInfo{}, nil - } - return nil, fmt.Errorf("failed to read directory: %w", err) - } - - var dirs []fs.FileInfo - for _, entry := range entries { - if entry.IsDir() { - info, infoErr := entry.Info() - if infoErr == nil { - dirs = append(dirs, info) - } - } - } - - return dirs, nil -} - -// GetDirectoryInfo gets information about a real directory using os.Stat -func (mr *MetadataReader) GetDirectoryInfo(virtualPath string) (fs.FileInfo, error) { - metadataPath := mr.service.GetMetadataDirectoryPath(virtualPath) - info, err := os.Stat(metadataPath) - if err != nil { - return nil, fmt.Errorf("directory not found: %w", err) - } - if !info.IsDir() { - return nil, fmt.Errorf("path is not a directory: %s", virtualPath) - } - return info, nil -} - // GetFileMetadata gets metadata for a virtual file func (mr *MetadataReader) GetFileMetadata(virtualPath string) (*metapb.FileMetadata, error) { return mr.service.ReadFileMetadata(virtualPath) } -// PathExists checks if a virtual path exists -func (mr *MetadataReader) PathExists(virtualPath string) (bool, error) { - // Check if it's a directory - if mr.service.DirectoryExists(virtualPath) { - return true, nil - } - - // Check if it's a file - if mr.service.FileExists(virtualPath) { - return true, nil - } - - return false, nil -} - -// IsDirectory checks if a virtual path is a directory -func (mr *MetadataReader) IsDirectory(virtualPath string) (bool, error) { - // Check if it's a directory - if mr.service.DirectoryExists(virtualPath) { - return true, nil - } - - // Check if it's a file - if mr.service.FileExists(virtualPath) { - return false, nil - } - - return false, fmt.Errorf("path does not exist: %s", virtualPath) -} - -// GetFileSegments retrieves usenet segments for a virtual file -func (mr *MetadataReader) GetFileSegments(virtualPath string) ([]*metapb.SegmentData, error) { - fileMeta, err := mr.service.ReadFileMetadata(virtualPath) - if err != nil { - return nil, fmt.Errorf("failed to read file metadata: %w", err) - } - - if fileMeta == nil { - return nil, fmt.Errorf("file not found: %s", virtualPath) - } - - // Return the protobuf segments directly - return fileMeta.SegmentData, nil -} - // GetMetadataService returns the underlying metadata service func (mr *MetadataReader) GetMetadataService() *MetadataService { return mr.service diff --git a/internal/metadata/service.go b/internal/metadata/service.go index b891ea108..016ee4ec4 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -395,28 +395,6 @@ func (ms *MetadataService) ListDirectoryAll(virtualPath string) (dirs []fs.FileI return dirs, fileNames, nil } -// ListSubdirectories lists all subdirectories in a metadata directory -func (ms *MetadataService) ListSubdirectories(virtualPath string) ([]string, error) { - metadataDir := filepath.Join(ms.rootPath, virtualPath) - - entries, err := os.ReadDir(metadataDir) - if err != nil { - if os.IsNotExist(err) { - return []string{}, nil // Directory not found, return empty list - } - return nil, fmt.Errorf("failed to read directory: %w", err) - } - - var dirs []string - for _, entry := range entries { - if entry.IsDir() { - dirs = append(dirs, entry.Name()) - } - } - - return dirs, nil -} - // CreateFileMetadata creates a new FileMetadata with basic fields func (ms *MetadataService) CreateFileMetadata( fileSize int64, @@ -609,30 +587,6 @@ func (ms *MetadataService) RenameFileMetadata(oldVirtualPath, newVirtualPath str return nil } -// WalkDirectoryFiles walks a metadata directory and calls fn for each file's virtual path and metadata. -func (ms *MetadataService) WalkDirectoryFiles(virtualPath string, fn func(fileVirtualPath string, meta *metapb.FileMetadata) error) error { - metadataDir := filepath.Join(ms.rootPath, virtualPath) - - return filepath.WalkDir(metadataDir, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".meta") { - return nil - } - - relPath, err := filepath.Rel(ms.rootPath, path) - if err != nil { - return nil - } - fileVirtualPath := strings.TrimSuffix(relPath, ".meta") - - meta, err := ms.ReadFileMetadata(fileVirtualPath) - if err != nil || meta == nil { - return nil - } - - return fn(fileVirtualPath, meta) - }) -} - // isCrossDeviceError checks if an error is a cross-device link error (EXDEV). func isCrossDeviceError(err error) bool { return strings.Contains(err.Error(), "cross-device") || strings.Contains(err.Error(), "invalid cross-device link") @@ -665,35 +619,6 @@ func copyAndRemoveFile(src, dst string) error { return os.Remove(src) } -// ValidateSourceNzb validates that the source NZB file exists and matches metadata -func (ms *MetadataService) ValidateSourceNzb(metadata *metapb.FileMetadata) error { - if metadata.SourceNzbPath == "" { - return fmt.Errorf("source NZB path is empty") - } - - // Check if source NZB file exists - if _, err := os.Stat(metadata.SourceNzbPath); err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("source NZB file not found: %s", metadata.SourceNzbPath) - } - return fmt.Errorf("failed to stat source NZB file: %w", err) - } - - return nil -} - -// CalculateSegmentSize calculates the total size from segment data -func (ms *MetadataService) CalculateSegmentSize(segments []*metapb.SegmentData) int64 { - var totalSize int64 - for _, segment := range segments { - segmentSize := segment.EndOffset - segment.StartOffset - if segmentSize > 0 { - totalSize += segmentSize - } - } - return totalSize -} - // GetMetadataFilePath returns the filesystem path for a metadata file func (ms *MetadataService) GetMetadataFilePath(virtualPath string) string { filename := filepath.Base(virtualPath) @@ -706,23 +631,10 @@ func (ms *MetadataService) GetMetadataDirectoryPath(virtualPath string) string { return filepath.Join(ms.rootPath, virtualPath) } -// CreateSegmentData creates a new SegmentData with the given parameters -func (ms *MetadataService) CreateSegmentData(startOffset, endOffset int64, messageID string) *metapb.SegmentData { - return &metapb.SegmentData{ - StartOffset: startOffset, - EndOffset: endOffset, - Id: messageID, - } -} - func (ms *MetadataService) CreateDirectory(name string) error { return os.MkdirAll(filepath.Join(ms.rootPath, name), 0755) } -func (ms *MetadataService) CreateDirectoryAll(name string) error { - return os.MkdirAll(filepath.Join(ms.rootPath, name), 0755) -} - // CleanupEmptyDirectories recursively removes empty directories under the given virtual path. // Uses a bottom-up approach to ensure parent directories are also removed if they become empty. func (ms *MetadataService) CleanupEmptyDirectories(virtualPath string, protected []string) error { diff --git a/internal/nzbfilesystem/constants.go b/internal/nzbfilesystem/constants.go index 49df47b21..7bd0e24ae 100644 --- a/internal/nzbfilesystem/constants.go +++ b/internal/nzbfilesystem/constants.go @@ -53,41 +53,19 @@ func (e *CorruptedFileError) Unwrap() error { // Error message constants var ( - ErrCannotRemoveRoot = errors.New("cannot remove root directory") - ErrCannotRenameRoot = errors.New("cannot rename root directory") - ErrCannotRenameToRoot = errors.New("cannot rename to root directory") - ErrDestinationExists = errors.New("destination already exists") - ErrNotDirectory = errors.New("not a directory") - ErrCannotReadDirectory = errors.New("cannot read from directory") - ErrNegativeOffset = errors.New("negative offset") - ErrVirtualFileNotInit = errors.New("virtual file not initialized") - ErrMissmatchedSegments = errors.New("missmatched segments for file size") - ErrNoUsenetPool = errors.New("usenet connection pool not configured") - ErrNoCipherConfig = errors.New("no cipher configured for encryption") - ErrNoEncryptionParams = errors.New("no NZB data available for encryption parameters") - ErrTruncateNotSupported = errors.New("truncate not supported for virtual files") - ErrWriteNotSupported = errors.New("write not supported for virtual files") - ErrFailedListDirectory = errors.New("failed to list directory contents") - ErrFileIsCorrupted = errors.New("file is corrupted, there are some missing segments") + ErrCannotRemoveRoot = errors.New("cannot remove root directory") + ErrNotDirectory = errors.New("not a directory") + ErrCannotReadDirectory = errors.New("cannot read from directory") + ErrNegativeOffset = errors.New("negative offset") + ErrMissmatchedSegments = errors.New("missmatched segments for file size") + ErrNoUsenetPool = errors.New("usenet connection pool not configured") + ErrNoCipherConfig = errors.New("no cipher configured for encryption") + ErrNoEncryptionParams = errors.New("no NZB data available for encryption parameters") + ErrFileIsCorrupted = errors.New("file is corrupted, there are some missing segments") ) // Database operation error message templates const ( - ErrMsgFailedQueryVirtualFile = "failed to query virtual file: %w" - ErrMsgFailedDeleteVirtualFile = "failed to delete virtual file: %w" - ErrMsgFailedCheckDestination = "failed to check destination: %w" - ErrMsgFailedFindParent = "failed to find parent directory: %w" - ErrMsgFailedMoveFile = "failed to move file: %w" - ErrMsgFailedUpdateFilename = "failed to update filename: %w" - ErrMsgFailedGetDescendants = "failed to get descendants: %w" - ErrMsgFailedUpdateDescPath = "failed to update descendant path: %w" - ErrMsgFailedListDirectory = "failed to list directory contents: %w" - ErrMsgFailedCreateUsenetReader = "failed to create usenet reader: %w" ErrMsgFailedCreateDecryptReader = "failed to create decrypt reader: %w" ErrMsgFailedWrapEncryption = "failed to wrap reader with encryption: %w" ) - -// Range validation error message templates -const ( - ErrMsgReadOutsideRange = "read offset %d is outside requested range %d-%d" -) diff --git a/internal/nzbfilesystem/metadata_remote_file.go b/internal/nzbfilesystem/metadata_remote_file.go index 5eace94a2..8db89a599 100644 --- a/internal/nzbfilesystem/metadata_remote_file.go +++ b/internal/nzbfilesystem/metadata_remote_file.go @@ -812,8 +812,6 @@ type MetadataVirtualFile struct { bufOffReader interface{ GetBufferedOffset() int64 } readerInitialized bool position int64 // File position (what client sees after Seek) - currentRangeStart int64 // Start of current reader's range - currentRangeEnd int64 // End of current reader's range originalRangeEnd int64 // Original end requested by client (-1 for unbounded) // readAtSharedNext is the next file offset that the shared reader can serve @@ -1566,10 +1564,6 @@ func (mvf *MetadataVirtualFile) ensureReader() error { start = mvf.readAtSharedNext } - // Track the current reader's range for progressive reading - mvf.currentRangeStart = start - mvf.currentRangeEnd = end - // Create reader for the calculated range using metadata segments if len(mvf.meta.NestedSources) > 0 { // Nested RAR: use multi-source reader diff --git a/internal/nzbfilesystem/nzb_filesystem.go b/internal/nzbfilesystem/nzb_filesystem.go index 66061b90b..e9b0679f5 100644 --- a/internal/nzbfilesystem/nzb_filesystem.go +++ b/internal/nzbfilesystem/nzb_filesystem.go @@ -4,7 +4,6 @@ import ( "context" "io/fs" "os" - "time" "github.com/javi11/altmount/internal/slogutil" "github.com/javi11/altmount/internal/utils" @@ -118,11 +117,6 @@ func (nfs *NzbFilesystem) Rename(ctx context.Context, oldName, newName string) e return nil } -// Create creates a new file (not supported - read-only filesystem) -func (nfs *NzbFilesystem) Create(name string) (afero.File, error) { - return nil, os.ErrPermission -} - // Mkdir creates a directory (not supported - read-only filesystem) func (nfs *NzbFilesystem) Mkdir(ctx context.Context, name string, perm os.FileMode) error { return nfs.remoteFile.Mkdir(ctx, name, perm) @@ -133,22 +127,3 @@ func (nfs *NzbFilesystem) MkdirAll(ctx context.Context, name string, perm os.Fil return nfs.remoteFile.MkdirAll(ctx, name, perm) } -// Chmod changes file permissions (not supported) -func (nfs *NzbFilesystem) Chmod(name string, mode os.FileMode) error { - return os.ErrPermission -} - -// Chown changes file ownership (not supported) -func (nfs *NzbFilesystem) Chown(name string, uid, gid int) error { - return os.ErrPermission -} - -// Chtimes changes file times (not supported) -func (nfs *NzbFilesystem) Chtimes(name string, atime, mtime time.Time) error { - return os.ErrPermission -} - -// GetRemoteFile returns the underlying MetadataRemoteFile for configuration updates -func (nfs *NzbFilesystem) GetRemoteFile() *MetadataRemoteFile { - return nfs.remoteFile -} diff --git a/internal/slogutil/data.go b/internal/slogutil/data.go index 6c661bc02..1befb945d 100644 --- a/internal/slogutil/data.go +++ b/internal/slogutil/data.go @@ -8,12 +8,6 @@ import ( type data map[string]slog.Attr -func (d data) append(attrs ...slog.Attr) { - for _, attr := range attrs { - d[attr.Key] = attr - } -} - type dataKey struct{} func cloneData(ctx context.Context) data { @@ -25,18 +19,6 @@ func cloneData(ctx context.Context) data { return maps.Clone(d) } -// WithAttrs returns a new context with the given attributes. -func WithAttrs(ctx context.Context, attrs ...slog.Attr) context.Context { - if len(attrs) == 0 { - return ctx - } - - d := cloneData(ctx) - d.append(attrs...) - - return context.WithValue(ctx, dataKey{}, d) -} - // With returns a new context with the given key-value pairs. func With(ctx context.Context, kvargs ...any) context.Context { if len(kvargs) == 0 { diff --git a/internal/utils/copy.go b/internal/utils/copy.go deleted file mode 100644 index 6b431234c..000000000 --- a/internal/utils/copy.go +++ /dev/null @@ -1,34 +0,0 @@ -package utils - -import ( - "context" - "io" -) - -func CopyWithCtx(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) { - buf := make([]byte, 1024) - defer func() { - buf = nil - }() - - var totalBytes int64 - - for { - select { - case <-ctx.Done(): - return totalBytes, ctx.Err() - default: - n, err := src.Read(buf) - if err != nil { - return totalBytes, err - } - - n, err = dst.Write(buf[:n]) - if err != nil { - return totalBytes, err - } - - totalBytes += int64(n) - } - } -} diff --git a/internal/utils/path.go b/internal/utils/path.go index 4351e3721..d41e803d1 100644 --- a/internal/utils/path.go +++ b/internal/utils/path.go @@ -110,47 +110,6 @@ func JoinAbsPath(basePath, otherPath string) string { return filepath.Join(basePath, filepath.FromSlash(relOther)) } -// NormalizeLibraryPath builds a clean absolute library path by combining prefix segments. -// It handles stripping existing prefixes from the input path to prevent duplication. -func NormalizeLibraryPath(relPath string, completeDir string, category string) string { - // Normalize relative path - relPath = strings.TrimPrefix(filepath.ToSlash(relPath), "/") - - // Clean segments for comparison - cleanComplete := strings.Trim(filepath.ToSlash(completeDir), "/") - cleanCategory := strings.Trim(category, "/") - - // 1. Strip existing /complete or /category prefix from the internal path to start clean - if cleanComplete != "" { - if after, ok := strings.CutPrefix(relPath, cleanComplete+"/"); ok { - relPath = after - } else if relPath == cleanComplete { - relPath = "" - } - } - if cleanCategory != "" { - if after, ok := strings.CutPrefix(relPath, cleanCategory+"/"); ok { - relPath = after - } else if relPath == cleanCategory { - relPath = "" - } - } - - // 2. Build the clean, isolated library path - // Construct: [CompleteDir] + [Category] + RelPath - pathParts := []string{} - if cleanComplete != "" { - pathParts = append(pathParts, cleanComplete) - } - if cleanCategory != "" { - pathParts = append(pathParts, cleanCategory) - } - pathParts = append(pathParts, relPath) - - finalPath := filepath.Join(pathParts...) - return filepath.ToSlash(filepath.Clean(finalPath)) -} - // CheckFileDirectoryWritable checks if the directory containing a file path is writable. func CheckFileDirectoryWritable(filePath string, fileType string) error { if filePath == "" { diff --git a/internal/utils/range.go b/internal/utils/range.go index 4d4338e86..43e90ec39 100644 --- a/internal/utils/range.go +++ b/internal/utils/range.go @@ -67,25 +67,3 @@ func ParseRangeHeader(s string) (po *RangeHeader, err error) { return &o, nil } - -// FixRangeHeader looks through the slice of options and adjusts any -// RangeHeader~s found that request a fetch from the end into an -// absolute fetch using the size passed in and makes sure the range does -// not exceed filesize. -func FixRangeHeader(rh *RangeHeader, size int64) *RangeHeader { - if size < 0 { - // Can't do anything for unknown length objects - return rh - } - - fixed := rh - if fixed.Start < 0 { - fixed = &RangeHeader{Start: size - fixed.End, End: -1} - } - // If end is too big or undefined, fetch to the end - if fixed.End > size || fixed.End < 0 { - fixed = &RangeHeader{Start: fixed.Start, End: size - 1} - } - - return fixed -} diff --git a/internal/webdav/adapter.go b/internal/webdav/adapter.go index 99fc67247..18f4b7ca0 100644 --- a/internal/webdav/adapter.go +++ b/internal/webdav/adapter.go @@ -434,11 +434,6 @@ func (h *Handler) GetHTTPHandler() http.Handler { return h.handler } -// GetAuthCredentials returns the auth credentials for dynamic updates -func (h *Handler) GetAuthCredentials() *AuthCredentials { - return h.authCreds -} - // SyncAuthCredentials updates auth credentials from current config func (h *Handler) SyncAuthCredentials() { if h.configGetter != nil { diff --git a/internal/webdav/auth_updater.go b/internal/webdav/auth_updater.go index 8e0bbeee9..2ed524059 100644 --- a/internal/webdav/auth_updater.go +++ b/internal/webdav/auth_updater.go @@ -1,9 +1,6 @@ package webdav import ( - "context" - "fmt" - "log/slog" "sync" ) @@ -36,35 +33,3 @@ func (ac *AuthCredentials) UpdateCredentials(username, password string) { ac.username = username ac.password = password } - -// AuthUpdater provides methods to update WebDAV authentication -type AuthUpdater struct { - credentials *AuthCredentials - logger *slog.Logger -} - -// NewAuthUpdater creates a new WebDAV auth updater -func NewAuthUpdater() *AuthUpdater { - return &AuthUpdater{ - logger: slog.Default().With("component", "webdav-auth-updater"), - } -} - -// SetAuthCredentials sets the auth credentials reference for dynamic updates -func (u *AuthUpdater) SetAuthCredentials(credentials *AuthCredentials) { - u.credentials = credentials -} - -// UpdateAuth updates WebDAV authentication credentials -func (u *AuthUpdater) UpdateAuth(username, password string) error { - if u.credentials == nil { - return fmt.Errorf("auth credentials not initialized") - } - - ctx := context.Background() - u.logger.InfoContext(ctx, "Updating WebDAV authentication credentials") - u.credentials.UpdateCredentials(username, password) - u.logger.InfoContext(ctx, "WebDAV authentication credentials updated successfully") - - return nil -} diff --git a/pkg/rclonecli/mount.go b/pkg/rclonecli/mount.go index 2719da0f3..0beec251e 100644 --- a/pkg/rclonecli/mount.go +++ b/pkg/rclonecli/mount.go @@ -79,23 +79,6 @@ func (m *Mount) IsMounted() bool { return m.rcManager.IsMounted(m.Provider) } -// RefreshDir refreshes directories in the mount -func (m *Mount) RefreshDir(ctx context.Context, dirs []string) error { - if m.rcManager == nil { - return fmt.Errorf("rclone manager is not available") - } - - if !m.IsMounted() { - return fmt.Errorf("provider %s not properly mounted. Skipping refreshes", m.Provider) - } - - if err := m.rcManager.RefreshDir(ctx, m.Provider, dirs); err != nil { - return fmt.Errorf("failed to refresh directories for %s: %w", m.Provider, err) - } - - return nil -} - // GetMountInfo returns mount information func (m *Mount) GetMountInfo() (*MountInfo, bool) { if m.rcManager == nil { From ffe65e290d63d4eae3dba82c4c6ffb5cd64cc17d Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:11:04 +0530 Subject: [PATCH 05/11] feat(arrs): restore add-error-rule input, refine cleanup copy Bring back the input bar for adding custom *arr error-match rules, and reword the grace-period and error-rules help text to read more clearly. --- .../components/config/ArrsConfigSection.tsx | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/config/ArrsConfigSection.tsx b/frontend/src/components/config/ArrsConfigSection.tsx index bddff26c6..427e1d93a 100644 --- a/frontend/src/components/config/ArrsConfigSection.tsx +++ b/frontend/src/components/config/ArrsConfigSection.tsx @@ -7,6 +7,7 @@ import type { ArrsType, ConfigResponse, StuckCleanupAction, + StuckCleanupRule, } from "../../types/config"; import { LoadingSpinner } from "../ui/LoadingSpinner"; import { ArrsInstanceCard } from "./ArrsInstanceCard"; @@ -61,6 +62,7 @@ export function ArrsConfigSection({ const [webhookError, setWebhookError] = useState(null); const [saveError, setSaveError] = useState(null); const [newIgnoreMessage, setNewIgnoreMessage] = useState(""); + const [newStuckPattern, setNewStuckPattern] = useState(""); const registerWebhooks = useRegisterArrsWebhooks(); const defaultWebhookUrl = `http://${config.webdav.host || "altmount"}:${config.webdav.port}`; @@ -215,6 +217,21 @@ export function ArrsConfigSection({ handleFormChange("queue_cleanup_allowlist", newList); }; + const handleAddStuckPattern = () => { + if (!newStuckPattern.trim()) return; + const currentList = formData.stuck_cleanup_rules || []; + if (currentList.some((m) => m.message === newStuckPattern.trim())) { + setNewStuckPattern(""); + return; + } + const newList: StuckCleanupRule[] = [ + ...currentList, + { message: newStuckPattern.trim(), enabled: true, action: "blocklist_search" }, + ]; + handleFormChange("stuck_cleanup_rules", newList); + setNewStuckPattern(""); + }; + const handleRemoveStuckPattern = (index: number) => { const newList = [...(formData.stuck_cleanup_rules || [])]; newList.splice(index, 1); @@ -580,18 +597,16 @@ export function ArrsConfigSection({ - Minutes an import must stay stuck before a rule acts (e.g. 1–5). Brief errors - the *arr clears on its own are ignored. + How long an import must stay stuck before a rule kicks in. Short-lived errors + that resolve on their own are left alone. - - Stuck Error Rules - + Error Rules - Match an *arr error message to an action: remove, blocklist, or blocklist + - search. + When a stuck import's error matches one of these, run the chosen action: remove, + blocklist, or blocklist + search. {(formData.stuck_cleanup_rules || []).map((rule, index) => ( @@ -635,6 +650,27 @@ export function ArrsConfigSection({ ))} + + {!isReadOnly && ( + + setNewStuckPattern(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAddStuckPattern()} + /> + + + + + )} )} 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} - - - handleRemoveIgnoreMessage(index)} - disabled={isReadOnly} - > - - - - ))} - - - {!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) => ( 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 (skipping duplicate messages). + rules := append([]StuckCleanupRule(nil), a.StuckCleanupRules...) + for _, m := range a.QueueCleanupAllowlist { + exists := false + for _, r := range rules { + if r.Message == m.Message { + exists = true + break + } + } + if !exists { + 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 + } + + // 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 +} + // ArrsInstanceConfig represents a single arrs instance configuration type ArrsInstanceConfig struct { Name string `yaml:"name" mapstructure:"name" json:"name"` @@ -1235,6 +1300,9 @@ func (m *Manager) ReloadConfig() error { } } + // Migrate: fold legacy stuck/allowlist cleanup config into the unified rules. + migrateArrsCleanup(config) + // Validate configuration if err := config.Validate(); err != nil { return fmt.Errorf("config validation failed: %w", err) @@ -1534,23 +1602,12 @@ func DefaultConfig(configDir ...string) *Config { WhisparrInstances: []ArrsInstanceConfig{}, SportarrInstances: []ArrsInstanceConfig{}, CleanupAutomaticImportFailure: &cleanupAutomaticImportFailure, - QueueCleanupGracePeriodMinutes: 10, // Default to 10 minutes - QueueCleanupAllowlist: []IgnoredMessage{ - {Message: "No files found are eligible", Enabled: true}, - {Message: "One or more episodes expected in this release were not imported or missing", Enabled: true}, - {Message: "is not a valid video file", Enabled: true}, - {Message: "Sample file", Enabled: true}, - {Message: "No video files were found in the selected folder", Enabled: true}, - {Message: "Could not find file", Enabled: true}, - {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 + 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 // don't search), or remove (just clear the queue: transient/environmental // errors or files that are already satisfied). - StuckCleanupRules: []StuckCleanupRule{ + QueueCleanupRules: []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}, @@ -1579,6 +1636,11 @@ func DefaultConfig(configDir ...string) *Config { {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}, + // Folded from the former queue-cleanup allowlist — remove from queue only. + {Message: "Sample file", Enabled: true, Action: StuckActionRemove}, + {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}, }, }, Fuse: FuseConfig{ @@ -1691,6 +1753,9 @@ func LoadConfig(configFile string) (*Config, error) { } } + // Migrate: fold legacy stuck/allowlist cleanup config into the unified rules. + migrateArrsCleanup(config) + // If log file was not explicitly set in the config file and we have a specific config file path, // derive log file path from config file location if configFile != "" && !viper.IsSet("log.file") { diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index 536a50c66..8408ce146 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -253,3 +253,67 @@ func TestConfig_NetworkDefaultsEmpty(t *testing.T) { assert.Empty(t, cfg.Network.NoProxy) } +func TestMigrateArrsCleanup_FoldsLegacyConfig(t *testing.T) { + legacyEnabled := true + cfg := &Config{ + Arrs: ArrsConfig{ + // No unified rules or enable flag / grace yet — simulate a pre-merge config. + StuckCleanupEnabled: &legacyEnabled, + StuckCleanupGracePeriodMinutes: 7, + StuckCleanupRules: []StuckCleanupRule{ + {Message: "is not a valid video file", Enabled: true, Action: StuckActionBlocklistSearch}, + }, + QueueCleanupAllowlist: []IgnoredMessage{ + {Message: "Could not find file", Enabled: true}, + // Duplicate of an existing rule message — must not be added twice. + {Message: "is not a valid video file", Enabled: false}, + }, + }, + } + + migrateArrsCleanup(cfg) + a := cfg.Arrs + + // Stuck rule kept; unique allowlist entry folded in as a remove rule; dup skipped. + assert.Equal(t, []StuckCleanupRule{ + {Message: "is not a valid video file", Enabled: true, Action: StuckActionBlocklistSearch}, + {Message: "Could not find file", Enabled: true, Action: StuckActionRemove}, + }, a.QueueCleanupRules) + + // Legacy enable + grace carried over. + assert.NotNil(t, a.QueueCleanupEnabled) + assert.True(t, *a.QueueCleanupEnabled) + assert.Equal(t, 7, a.QueueCleanupGracePeriodMinutes) + + // Legacy fields cleared so they drop out of saved YAML. + assert.Nil(t, a.QueueCleanupAllowlist) + assert.Nil(t, a.StuckCleanupEnabled) + assert.Nil(t, a.StuckCleanupRules) + assert.Zero(t, a.StuckCleanupGracePeriodMinutes) + + // Idempotent: a second pass changes nothing. + before := a.QueueCleanupRules + migrateArrsCleanup(cfg) + assert.Equal(t, before, cfg.Arrs.QueueCleanupRules) + + // Saved YAML must not emit any legacy keys. + b, err := yaml.Marshal(cfg.Arrs) + assert.NoError(t, err) + out := string(b) + assert.Contains(t, out, "queue_cleanup_rules:") + assert.NotContains(t, out, "queue_cleanup_allowlist") + assert.NotContains(t, out, "stuck_cleanup_enabled") + assert.NotContains(t, out, "stuck_cleanup_grace_period_minutes") + assert.NotContains(t, out, "stuck_cleanup_rules") +} + +func TestMigrateArrsCleanup_NoLegacyNoop(t *testing.T) { + rules := []StuckCleanupRule{ + {Message: "Sample", Enabled: true, Action: StuckActionBlocklistSearch}, + } + cfg := &Config{Arrs: ArrsConfig{QueueCleanupRules: rules}} + migrateArrsCleanup(cfg) + // Unified-only config is left untouched. + assert.Equal(t, rules, cfg.Arrs.QueueCleanupRules) +} + From 17cd803004b00c6f71d4d240af9a5820e1a04c9f Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:54:00 +0530 Subject: [PATCH 07/11] fix(arrs): include Sportarr in config response; drop duplicate Sample rule Sportarr instances were saved to disk but omitted from the arrs config API response, so they vanished from the UI on reload. Add SportarrInstances to the response struct and mapping. Also remove the default 'Sample file' cleanup rule (the substring 'Sample' rule already covers it) and make the legacy-config migration skip allowlist entries already matched by an existing rule, so no dead duplicate rules are carried over. --- internal/api/types.go | 2 ++ internal/config/manager.go | 16 ++++++++++------ internal/config/manager_test.go | 8 ++++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/api/types.go b/internal/api/types.go index 840a2f0e4..9b446732e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -179,6 +179,7 @@ type ArrsAPIResponse struct { LidarrInstances []ArrsInstanceAPIResponse `json:"lidarr_instances"` ReadarrInstances []ArrsInstanceAPIResponse `json:"readarr_instances"` WhisparrInstances []ArrsInstanceAPIResponse `json:"whisparr_instances"` + 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"` @@ -361,6 +362,7 @@ func ToConfigAPIResponse(cfg *config.Config, apiKey string) *ConfigAPIResponse { LidarrInstances: toArrsInstances(cfg.Arrs.LidarrInstances), ReadarrInstances: toArrsInstances(cfg.Arrs.ReadarrInstances), WhisparrInstances: toArrsInstances(cfg.Arrs.WhisparrInstances), + 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, diff --git a/internal/config/manager.go b/internal/config/manager.go index 02391a97d..197f46ef3 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -509,17 +509,20 @@ func migrateArrsCleanup(config *Config) { } // Rebuild the unified rules from the legacy config: the stuck rules verbatim, - // then allowlist entries as plain "remove" rules (skipping duplicate messages). + // 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 { - exists := false + covered := false for _, r := range rules { - if r.Message == m.Message { - exists = true + if r.Message == m.Message || (r.Message != "" && strings.Contains(m.Message, r.Message)) { + covered = true break } } - if !exists { + if !covered { rules = append(rules, StuckCleanupRule{ Message: m.Message, Enabled: m.Enabled, @@ -1637,7 +1640,8 @@ func DefaultConfig(configDir ...string) *Config { {Message: "is reporting an error", Enabled: false, Action: StuckActionRemove}, {Message: "Import failed, path does not exist", Enabled: false, Action: StuckActionRemove}, // Folded from the former queue-cleanup allowlist — remove from queue only. - {Message: "Sample file", Enabled: true, Action: StuckActionRemove}, + // ("Sample file" is intentionally omitted: the "Sample" rule above already + // substring-matches it.) {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}, diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index 8408ce146..1b9295172 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -265,8 +265,11 @@ func TestMigrateArrsCleanup_FoldsLegacyConfig(t *testing.T) { }, QueueCleanupAllowlist: []IgnoredMessage{ {Message: "Could not find file", Enabled: true}, - // Duplicate of an existing rule message — must not be added twice. + // Exact duplicate of an existing rule message — must not be added twice. {Message: "is not a valid video file", Enabled: false}, + // Substring-covered by the "is not a valid video file" rule above + // (matching is substring-based) — must be skipped as a dead duplicate. + {Message: "is not a valid video file (sample)", Enabled: true}, }, }, } @@ -274,7 +277,8 @@ func TestMigrateArrsCleanup_FoldsLegacyConfig(t *testing.T) { migrateArrsCleanup(cfg) a := cfg.Arrs - // Stuck rule kept; unique allowlist entry folded in as a remove rule; dup skipped. + // Stuck rule kept; unique allowlist entry folded in as a remove rule; exact and + // substring-covered duplicates skipped. assert.Equal(t, []StuckCleanupRule{ {Message: "is not a valid video file", Enabled: true, Action: StuckActionBlocklistSearch}, {Message: "Could not find file", Enabled: true, Action: StuckActionRemove}, From 43fe72b48919ba3cce486371b125ec85c74a2c02 Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:01:17 +0530 Subject: [PATCH 08/11] feat(arrs): clarify interval vs grace-period copy, align grace default Add a one-line description to Cleanup Interval (how often queues are checked) and reword Cleanup Grace Period to cover both stuck and failed imports (how long one must persist before cleanup acts), so the two fields read distinctly. Align the grace-period input default with the backend (5 min). --- frontend/src/components/config/ArrsConfigSection.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/config/ArrsConfigSection.tsx b/frontend/src/components/config/ArrsConfigSection.tsx index 966b57353..aa341a0fc 100644 --- a/frontend/src/components/config/ArrsConfigSection.tsx +++ b/frontend/src/components/config/ArrsConfigSection.tsx @@ -400,6 +400,9 @@ export function ArrsConfigSection({ sec + + 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 - + - handleFormChange("cleanup_automatic_import_failure", e.target.checked) + handleFormChange( + "queue_cleanup_grace_period_minutes", + Number.parseInt(e.target.value, 10) || 5, + ) } + min={0} disabled={isReadOnly} /> - - Purge Automatic Failures + + min - - - Automatically remove items from queue that failed with "Automatic Import" - errors. + + + How long a stuck or failed import must persist before cleanup acts on it. + Brief errors that clear on their own are ignored. 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
@@ -117,7 +117,7 @@ export function PruneStatsModal({ isPending, onClose, onPrune }: PruneStatsModal diff --git a/frontend/src/pages/HealthPage/components/IndexerHealth/types.ts b/frontend/src/pages/HealthPage/components/IndexerHealth/types.ts index 8a67793c3..f41238c4f 100644 --- a/frontend/src/pages/HealthPage/components/IndexerHealth/types.ts +++ b/frontend/src/pages/HealthPage/components/IndexerHealth/types.ts @@ -12,8 +12,6 @@ export interface IndexerSummary { totalSuccess: number; totalFailed: number; overallRate: number; - best: IndexerStat; - worst: IndexerStat; } export type SortKey = "health" | "total" | "name"; From 9007b1b594c132fb7255ce838508b967ceb5d148 Mon Sep 17 00:00:00 2001 From: yoshi <73191510+yoshitaka420@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:24:14 +0530 Subject: [PATCH 04/11] feat(health): theme-consistent colors for provider health Replace hardcoded tailwind palette colors and rgba glows with daisyUI theme tokens across the provider health UI, matching the indexer health page: - charts: palette + tooltip/cursor sourced from theme vars (var(--color-*)) instead of fixed hex; fixed stale daisyUI v4 hsl(var(--bc/--b1)) tooltip - provider table badges/dots: emerald/amber/rose -> success/warning/error - quota vials + slate text: themed via base/success/warning/error tokens (index.css vial liquid/glass), drop hardcoded glow shadows --- frontend/bun.lock | 6 - frontend/package.json | 4 +- frontend/src/api/client.ts | 62 - .../src/components/charts/HealthChart.tsx | 87 -- frontend/src/components/charts/QueueChart.tsx | 49 - .../components/config/RCloneConfigSection.tsx | 1019 ----------------- .../components/queue/ManualScanSection.tsx | 209 ---- .../components/system/ActiveStreamsCard.tsx | 205 ---- .../components/system/RecentCompletions.tsx | 38 - frontend/src/components/ui/BytesDisplay.tsx | 3 - frontend/src/components/ui/ErrorAlert.tsx | 22 - frontend/src/components/ui/KeyValueEditor.tsx | 102 -- frontend/src/components/ui/LoadingSpinner.tsx | 12 - frontend/src/contexts/ModalContext.tsx | 2 +- frontend/src/hooks/useConfig.ts | 16 - frontend/src/index.css | 42 +- frontend/src/pages/ConfigurationPage.tsx | 9 - .../HealthPage/components/IndexerHealth.tsx | 2 - .../ProviderHealth/ProviderHealth.tsx | 30 +- .../ProviderHealth/ProviderQuota.tsx | 18 +- .../components/ProviderHealth/chartShared.tsx | 41 +- frontend/src/pages/QueuePage.tsx | 11 +- frontend/src/services/webdavClient.ts | 26 - frontend/src/types/api.ts | 8 - frontend/src/types/config.ts | 147 --- internal/api/auth_updater.go | 29 - internal/api/response.go | 10 - internal/api/server.go | 18 - internal/api/types.go | 19 - internal/config/manager.go | 13 - .../database/{testing.go => testing_test.go} | 0 internal/encryption/rclone/utils.go | 28 - internal/fuse/server.go | 15 - internal/health/checker.go | 6 - internal/health/library_sync.go | 2 +- internal/health/scheduler.go | 2 +- internal/health/worker.go | 14 - internal/httpclient/client.go | 7 - internal/httpclient/proxy.go | 7 +- internal/importer/interfaces.go | 66 -- internal/library/library.go | 221 ---- internal/metadata/reader.go | 96 -- internal/metadata/service.go | 88 -- internal/nzbfilesystem/constants.go | 40 +- .../nzbfilesystem/metadata_remote_file.go | 6 - internal/nzbfilesystem/nzb_filesystem.go | 25 - internal/slogutil/data.go | 18 - internal/utils/copy.go | 34 - internal/utils/path.go | 41 - internal/utils/range.go | 22 - internal/webdav/adapter.go | 5 - internal/webdav/auth_updater.go | 35 - pkg/rclonecli/mount.go | 17 - 53 files changed, 94 insertions(+), 2960 deletions(-) delete mode 100644 frontend/src/components/charts/HealthChart.tsx delete mode 100644 frontend/src/components/charts/QueueChart.tsx delete mode 100644 frontend/src/components/config/RCloneConfigSection.tsx delete mode 100644 frontend/src/components/queue/ManualScanSection.tsx delete mode 100644 frontend/src/components/system/ActiveStreamsCard.tsx delete mode 100644 frontend/src/components/system/RecentCompletions.tsx delete mode 100644 frontend/src/components/ui/KeyValueEditor.tsx delete mode 100644 frontend/src/pages/HealthPage/components/IndexerHealth.tsx delete mode 100644 internal/api/auth_updater.go rename internal/database/{testing.go => testing_test.go} (100%) delete mode 100644 internal/library/library.go delete mode 100644 internal/utils/copy.go diff --git a/frontend/bun.lock b/frontend/bun.lock index 27e206357..5dd2e0814 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -15,11 +15,9 @@ "lucide-react": "^0.540.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-hook-form": "^7.62.0", "react-router-dom": "^7.8.1", "recharts": "^3.1.2", "webdav": "^5.8.0", - "zod": "^4.0.17", }, "devDependencies": { "@biomejs/biome": "^2.2.2", @@ -945,8 +943,6 @@ "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="], - "react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="], - "react-is": ["react-is@19.1.1", "", {}, "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" } }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], @@ -1181,8 +1177,6 @@ "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], diff --git a/frontend/package.json b/frontend/package.json index 86604fb54..4be04a56a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,11 +22,9 @@ "lucide-react": "^0.540.0", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-hook-form": "^7.62.0", "react-router-dom": "^7.8.1", "recharts": "^3.1.2", - "webdav": "^5.8.0", - "zod": "^4.0.17" + "webdav": "^5.8.0" }, "devDependencies": { "@biomejs/biome": "^2.2.2", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f85c7c904..2ea43a4fa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -24,7 +24,6 @@ import type { QueueHistoricalStatsResponse, QueueItem, QueueStats, - SABnzbdAddResponse, ScanStatusResponse, SystemBrowseResponse, UploadNZBLnkResponse, @@ -248,10 +247,6 @@ export class APIClient { return this.requestWithMeta(`/queue${query ? `?${query}` : ""}`); } - async getQueueItem(id: number) { - return this.request(`/queue/${id}`); - } - async deleteQueueItem(id: number) { return this.request(`/queue/${id}`, { method: "DELETE" }); } @@ -385,10 +380,6 @@ export class APIClient { return this.requestWithMeta(`/health${query ? `?${query}` : ""}`); } - async getHealthItem(id: string) { - return this.request(`/health/${encodeURIComponent(id)}`); - } - async deleteHealthItem(id: number, options?: { deleteMeta?: boolean; deleteSymlink?: boolean }) { const searchParams = new URLSearchParams(); if (options?.deleteMeta) searchParams.set("delete_meta", "true"); @@ -444,13 +435,6 @@ export class APIClient { }); } - async retryHealthItem(id: string, resetStatus?: boolean) { - return this.request(`/health/${encodeURIComponent(id)}/retry`, { - method: "POST", - body: JSON.stringify({ reset_status: resetStatus }), - }); - } - async repairHealthItem(id: number, resetRepairRetryCount?: boolean) { return this.request(`/health/${id}/repair`, { method: "POST", @@ -458,15 +442,6 @@ export class APIClient { }); } - async getCorruptedFiles(params?: { limit?: number; offset?: number }) { - const searchParams = new URLSearchParams(); - if (params?.limit) searchParams.set("limit", params.limit.toString()); - if (params?.offset) searchParams.set("offset", params.offset.toString()); - - const query = searchParams.toString(); - return this.request(`/health/corrupted${query ? `?${query}` : ""}`); - } - async getHealthStats() { return this.request("/health/stats"); } @@ -686,10 +661,6 @@ export class APIClient { }); } - async getArrsHealth() { - return this.request>("/arrs/health"); - } - async registerArrsWebhooks() { return this.request<{ message: string }>("/arrs/webhook/register", { method: "POST", @@ -742,13 +713,6 @@ export class APIClient { return this.request("/config"); } - async updateConfig(config: ConfigUpdateRequest) { - return this.request("/config", { - method: "PUT", - body: JSON.stringify(config), - }); - } - async updateConfigSection(section: ConfigSection, config: ConfigUpdateRequest) { return this.request(`/config/${section}`, { method: "PATCH", @@ -941,32 +905,6 @@ export class APIClient { }); } - // SABnzbd file upload endpoint - async uploadNzbFile(file: File, apiKey: string): Promise { - const formData = new FormData(); - formData.append("nzbfile", file); - - const url = `/sabnzbd?mode=addfile&apikey=${encodeURIComponent(apiKey)}`; - - const response = await fetch(url, { - method: "POST", - body: formData, - credentials: "include", // Include cookies for Safari compatibility - }); - - if (!response.ok) { - throw new APIError(response.status, `Upload failed: ${response.statusText}`, ""); - } - - const data = await response.json(); - if (!data.status) { - const err = data as APIError; - throw new APIError(response.status, err.message || "Upload failed", err.details || ""); - } - - return data; - } - // Native upload endpoint using JWT authentication async uploadToQueue( file: File, diff --git a/frontend/src/components/charts/HealthChart.tsx b/frontend/src/components/charts/HealthChart.tsx deleted file mode 100644 index a11eaea55..000000000 --- a/frontend/src/components/charts/HealthChart.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { - Bar, - BarChart, - CartesianGrid, - Cell, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; -import { useHealthStats } from "../../hooks/useApi"; -import { LoadingSpinner } from "../ui/LoadingSpinner"; - -export function HealthChart() { - const { data: stats, isLoading, error } = useHealthStats(); - - if (isLoading) { - return ( - - - - ); - } - - if (error || !stats) { - return ( - - Failed to load health statistics - - ); - } - - // Include all categories to maintain consistent x-axis, even if zero - const data = [ - { name: "Healthy", value: stats.healthy, color: "#10b981" }, // success - { name: "Checking", value: stats.checking, color: "#3b82f6" }, // info - { name: "Pending", value: stats.pending, color: "#f59e0b" }, // warning - { name: "Repairing", value: stats.repair_triggered, color: "#8b5cf6" }, // purple - { name: "Corrupted", value: stats.corrupted, color: "#ef4444" }, // error - ]; - - // Check if all values are zero - if (data.every((item) => item.value === 0)) { - return ( - - No files tracked - - ); - } - - return ( - - - - - - - - {data.map((entry, index) => ( - - ))} - - - - ); -} diff --git a/frontend/src/components/charts/QueueChart.tsx b/frontend/src/components/charts/QueueChart.tsx deleted file mode 100644 index 5a9dfdf2a..000000000 --- a/frontend/src/components/charts/QueueChart.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; -import { useQueueStats } from "../../hooks/useApi"; -import { LoadingSpinner } from "../ui/LoadingSpinner"; - -export function QueueChart() { - const { data: stats, isLoading, error } = useQueueStats(); - - if (isLoading) { - return ( - - - - ); - } - - if (error || !stats) { - return ( - - Failed to load queue statistics - - ); - } - - const data = [ - { name: "Queued", value: stats.total_queued, fill: "#f59e0b" }, - { name: "Processing", value: stats.total_processing, fill: "#3b82f6" }, - { name: "Completed", value: stats.total_completed, fill: "#10b981" }, - { name: "Failed", value: stats.total_failed, fill: "#ef4444" }, - ]; - - return ( - - - - - - - - - - ); -} diff --git a/frontend/src/components/config/RCloneConfigSection.tsx b/frontend/src/components/config/RCloneConfigSection.tsx deleted file mode 100644 index 373995627..000000000 --- a/frontend/src/components/config/RCloneConfigSection.tsx +++ /dev/null @@ -1,1019 +0,0 @@ -import { Eye, EyeOff, HardDrive, Play, Save, Square, TestTube } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { useConfirm } from "../../contexts/ModalContext"; -import { useToast } from "../../contexts/ToastContext"; -import type { - ConfigResponse, - MountStatus, - RCloneMountFormData, - RCloneRCFormData, -} from "../../types/config"; -import { KeyValueEditor } from "../ui/KeyValueEditor"; - -interface RCloneConfigSectionProps { - config: ConfigResponse; - onUpdate?: ( - section: string, - data: - | Partial - | Partial - | { mount_path: string } - | { rclone: RCloneMountFormData; mount_path: string }, - ) => Promise; - isReadOnly?: boolean; - isUpdating?: boolean; -} - -export function RCloneConfigSection({ - config, - onUpdate, - isReadOnly = false, - isUpdating = false, -}: RCloneConfigSectionProps) { - const [formData, setFormData] = useState({ - rc_enabled: config.rclone.rc_enabled, - rc_url: config.rclone.rc_url, - vfs_name: config.rclone.vfs_name || "altmount", - rc_port: config.rclone.rc_port, - rc_user: config.rclone.rc_user, - rc_pass: "", - rc_options: config.rclone.rc_options, - }); - - const [mountFormData, setMountFormData] = useState({ - mount_enabled: config.rclone.mount_enabled || false, - mount_options: config.rclone.mount_options || {}, - - // Mount-Specific Settings - allow_other: config.rclone.allow_other || true, - allow_non_empty: config.rclone.allow_non_empty || true, - read_only: config.rclone.read_only || false, - timeout: config.rclone.timeout || "10m", - syslog: config.rclone.syslog || true, - - // System and filesystem options - log_level: config.rclone.log_level || "INFO", - uid: config.rclone.uid || 1000, - gid: config.rclone.gid || 1000, - umask: config.rclone.umask || "002", - buffer_size: config.rclone.buffer_size || "32M", - attr_timeout: config.rclone.attr_timeout || "1s", - transfers: config.rclone.transfers || 4, - - // VFS Cache Settings - cache_dir: config.rclone.cache_dir || "", - vfs_cache_mode: config.rclone.vfs_cache_mode || "full", - vfs_cache_max_size: config.rclone.vfs_cache_max_size || "50G", - vfs_cache_max_age: config.rclone.vfs_cache_max_age || "504h", - vfs_cache_poll_interval: config.rclone.vfs_cache_poll_interval || "1m", - vfs_read_chunk_size: config.rclone.vfs_read_chunk_size || "32M", - vfs_read_chunk_size_limit: config.rclone.vfs_read_chunk_size_limit || "2G", - vfs_read_ahead: config.rclone.vfs_read_ahead || "128M", - dir_cache_time: config.rclone.dir_cache_time || "10m", - vfs_cache_min_free_space: config.rclone.vfs_cache_min_free_space || "1G", - vfs_disk_space_total: config.rclone.vfs_disk_space_total || "1G", - vfs_read_chunk_streams: config.rclone.vfs_read_chunk_streams || 4, - - // Advanced Settings - no_mod_time: config.rclone.no_mod_time || false, - no_checksum: config.rclone.no_checksum || false, - async_read: config.rclone.async_read || true, - vfs_fast_fingerprint: config.rclone.vfs_fast_fingerprint || false, - use_mmap: config.rclone.use_mmap || false, - links: config.rclone.links || false, - }); - - // Separate state for mount path since it's a root-level config - const [mountPath, setMountPath] = useState(config.mount_path || "/mnt/remotes/altmount"); - - const [mountStatus, setMountStatus] = useState(null); - const [hasChanges, setHasChanges] = useState(false); - const [hasMountChanges, setHasMountChanges] = useState(false); - const [hasMountPathChanges, setHasMountPathChanges] = useState(false); - const [showRCPassword, setShowRCPassword] = useState(false); - const [isTestingConnection, setIsTestingConnection] = useState(false); - const [testResult, setTestResult] = useState<{ - success: boolean; - message: string; - } | null>(null); - const [isMountLoading, setIsMountLoading] = useState(false); - const [isMountToggleSaving, setIsMountToggleSaving] = useState(false); - const [isRCToggleSaving, setIsRCToggleSaving] = useState(false); - const { showToast } = useToast(); - const { confirmAction } = useConfirm(); - - // Sync form data when config changes from external sources (reload) - useEffect(() => { - const newFormData = { - rc_enabled: config.rclone.rc_enabled, - rc_url: config.rclone.rc_url, - vfs_name: config.rclone.vfs_name || "altmount", - rc_port: config.rclone.rc_port, - rc_user: config.rclone.rc_user, - rc_pass: "", - rc_options: config.rclone.rc_options, - }; - setFormData(newFormData); - setHasChanges(false); - - const newMountFormData = { - mount_enabled: config.rclone.mount_enabled || false, - mount_options: config.rclone.mount_options || {}, - - // Mount-Specific Settings - allow_other: config.rclone.allow_other || true, - allow_non_empty: config.rclone.allow_non_empty || true, - read_only: config.rclone.read_only || false, - timeout: config.rclone.timeout || "10m", - syslog: config.rclone.syslog || true, - - // System and filesystem options - log_level: config.rclone.log_level || "INFO", - uid: config.rclone.uid || 1000, - gid: config.rclone.gid || 1000, - umask: config.rclone.umask || "002", - buffer_size: config.rclone.buffer_size || "32M", - attr_timeout: config.rclone.attr_timeout || "1s", - transfers: config.rclone.transfers || 4, - - // VFS Cache Settings - cache_dir: config.rclone.cache_dir || "", - vfs_cache_mode: config.rclone.vfs_cache_mode || "full", - vfs_cache_max_size: config.rclone.vfs_cache_max_size || "50G", - vfs_cache_max_age: config.rclone.vfs_cache_max_age || "504h", - vfs_cache_poll_interval: config.rclone.vfs_cache_poll_interval || "1m", - vfs_read_chunk_size: config.rclone.vfs_read_chunk_size || "32M", - vfs_read_chunk_size_limit: config.rclone.vfs_read_chunk_size_limit || "2G", - vfs_read_ahead: config.rclone.vfs_read_ahead || "128M", - dir_cache_time: config.rclone.dir_cache_time || "10m", - vfs_cache_min_free_space: config.rclone.vfs_cache_min_free_space || "1G", - vfs_disk_space_total: config.rclone.vfs_disk_space_total || "1G", - vfs_read_chunk_streams: config.rclone.vfs_read_chunk_streams || 4, - - // Advanced Settings - no_mod_time: config.rclone.no_mod_time || false, - no_checksum: config.rclone.no_checksum || false, - async_read: config.rclone.async_read || true, - vfs_fast_fingerprint: config.rclone.vfs_fast_fingerprint || false, - use_mmap: config.rclone.use_mmap || false, - links: config.rclone.links || false, - }; - setMountFormData(newMountFormData); - setHasMountChanges(false); - - setMountPath(config.mount_path || "/mnt/remotes/altmount"); - setHasMountPathChanges(false); - }, [config]); - - const fetchMountStatus = useCallback(async () => { - try { - const response = await fetch("/api/mount/status"); - if (response.ok) { - const data = await response.json(); - setMountStatus(data.data); - } - } catch (error) { - console.error("Failed to fetch mount status:", error); - } - }, []); - - useEffect(() => { - fetchMountStatus(); - const interval = setInterval(fetchMountStatus, 5000); - return () => clearInterval(interval); - }, [fetchMountStatus]); - - const handleInputChange = ( - field: keyof RCloneRCFormData, - value: string | number | boolean | Record, - ) => { - const newFormData = { ...formData, [field]: value }; - setFormData(newFormData); - - // Compare with initial config to see if there are changes - const initialFormData = { - rc_enabled: config.rclone.rc_enabled, - rc_url: config.rclone.rc_url, - vfs_name: config.rclone.vfs_name || "altmount", - rc_port: config.rclone.rc_port, - rc_user: config.rclone.rc_user, - rc_pass: "", - rc_options: config.rclone.rc_options, - }; - setHasChanges(JSON.stringify(newFormData) !== JSON.stringify(initialFormData)); - }; - - const handleMountInputChange = ( - field: keyof RCloneMountFormData, - value: string | number | boolean | Record, - ) => { - const newMountFormData = { ...mountFormData, [field]: value }; - setMountFormData(newMountFormData); - - // Compare with initial config to see if there are changes - const initialMountFormData = { - mount_enabled: config.rclone.mount_enabled || false, - mount_options: config.rclone.mount_options || {}, - - // Mount-Specific Settings - allow_other: config.rclone.allow_other || true, - allow_non_empty: config.rclone.allow_non_empty || true, - read_only: config.rclone.read_only || false, - timeout: config.rclone.timeout || "10m", - syslog: config.rclone.syslog || true, - - // System and filesystem options - log_level: config.rclone.log_level || "INFO", - uid: config.rclone.uid || 1000, - gid: config.rclone.gid || 1000, - umask: config.rclone.umask || "002", - buffer_size: config.rclone.buffer_size || "32M", - attr_timeout: config.rclone.attr_timeout || "1s", - transfers: config.rclone.transfers || 4, - - // VFS Cache Settings - cache_dir: config.rclone.cache_dir || "", - vfs_cache_mode: config.rclone.vfs_cache_mode || "full", - vfs_cache_max_size: config.rclone.vfs_cache_max_size || "50G", - vfs_cache_max_age: config.rclone.vfs_cache_max_age || "504h", - vfs_cache_poll_interval: config.rclone.vfs_cache_poll_interval || "1m", - vfs_read_chunk_size: config.rclone.vfs_read_chunk_size || "32M", - vfs_read_chunk_size_limit: config.rclone.vfs_read_chunk_size_limit || "2G", - vfs_read_ahead: config.rclone.vfs_read_ahead || "128M", - dir_cache_time: config.rclone.dir_cache_time || "10m", - vfs_cache_min_free_space: config.rclone.vfs_cache_min_free_space || "1G", - vfs_disk_space_total: config.rclone.vfs_disk_space_total || "1G", - vfs_read_chunk_streams: config.rclone.vfs_read_chunk_streams || 4, - - // Advanced Settings - no_mod_time: config.rclone.no_mod_time || false, - no_checksum: config.rclone.no_checksum || false, - async_read: config.rclone.async_read || true, - vfs_fast_fingerprint: config.rclone.vfs_fast_fingerprint || false, - use_mmap: config.rclone.use_mmap || false, - links: config.rclone.links || false, - }; - setHasMountChanges(JSON.stringify(newMountFormData) !== JSON.stringify(initialMountFormData)); - }; - - const handleMountPathChange = (value: string) => { - setMountPath(value); - setHasMountPathChanges(value !== config.mount_path); - }; - - const handleSave = async () => { - if (onUpdate && hasChanges) { - const saveDelta: Partial = { ...formData }; - // Don't send empty password if it hasn't changed - if (saveDelta.rc_pass === "") { - delete saveDelta.rc_pass; - } - await onUpdate("rclone", saveDelta); - setHasChanges(false); - } - }; - - const handleSaveMount = async () => { - if (onUpdate && (hasMountChanges || hasMountPathChanges)) { - // We need to send both together if they changed - await onUpdate("rclone", { - rclone: mountFormData, - mount_path: mountPath, - }); - setHasMountChanges(false); - setHasMountPathChanges(false); - } - }; - - const handleRCEnabledChange = async (enabled: boolean) => { - if (onUpdate) { - setIsRCToggleSaving(true); - try { - await onUpdate("rclone", { rc_enabled: enabled }); - } finally { - setIsRCToggleSaving(false); - } - } - }; - - const handleMountEnabledChange = async (enabled: boolean) => { - if (onUpdate) { - setIsMountToggleSaving(true); - try { - await onUpdate("rclone", { mount_enabled: enabled }); - } finally { - setIsMountToggleSaving(false); - } - } - }; - - const handleTestConnection = async () => { - setIsTestingConnection(true); - setTestResult(null); - try { - const response = await fetch("/api/mount/test-rc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - const data = await response.json(); - setTestResult({ - success: data.success, - message: data.success - ? "Connection successful!" - : data.error?.message || "Connection failed", - }); - } catch (_error) { - setTestResult({ - success: false, - message: "Failed to connect to RC server", - }); - } finally { - setIsTestingConnection(false); - } - }; - - const handleStartMount = async () => { - const confirmed = await confirmAction( - "Start RClone Mount", - `This will attempt to mount the WebDAV filesystem at ${mountPath}. Continue?`, - ); - if (!confirmed) return; - - setIsMountLoading(true); - try { - const response = await fetch("/api/mount/start", { method: "POST" }); - if (response.ok) { - showToast({ - type: "success", - title: "Mount Started", - message: "RClone mount initiated successfully", - }); - fetchMountStatus(); - } else { - const errorData = await response.json(); - showToast({ - type: "error", - title: "Mount Failed", - message: errorData.error?.message || "Failed to start mount", - }); - } - } catch (_error) { - showToast({ - type: "error", - title: "Error", - message: "Failed to communicate with API", - }); - } finally { - setIsMountLoading(false); - } - }; - - const handleStopMount = async () => { - const confirmed = await confirmAction( - "Stop RClone Mount", - "This will unmount the WebDAV filesystem. Any applications accessing it may experience errors. Continue?", - { type: "warning", confirmText: "Stop Mount" }, - ); - if (!confirmed) return; - - setIsMountLoading(true); - try { - const response = await fetch("/api/mount/stop", { method: "POST" }); - if (response.ok) { - showToast({ - type: "info", - title: "Mount Stopped", - message: "RClone mount stopped successfully", - }); - fetchMountStatus(); - } else { - const errorData = await response.json(); - showToast({ - type: "error", - title: "Stop Failed", - message: errorData.error?.message || "Failed to stop mount", - }); - } - } catch (_error) { - showToast({ - type: "error", - title: "Error", - message: "Failed to communicate with API", - }); - } finally { - setIsMountLoading(false); - } - }; - - return ( - - - RClone Filesystem - - Manage the virtual mount and Remote Control (RC) interface. - - - - - {/* Mount Configuration Section */} - - - - - Mount Configuration - - - - - - Enable Internal Mount - - - Let AltMount manage and mount the virtual filesystem automatically - {isMountToggleSaving && ( - - )} - - handleMountEnabledChange(e.target.checked)} - /> - - - {isMountToggleSaving - ? "Saving..." - : "Highly recommended for all-in-one Docker setups"} - - - - {mountFormData.mount_enabled && ( - <> - - - - - Mount Point Path - handleMountPathChange(e.target.value)} - placeholder="/mnt/remotes/altmount" - /> - - Absolute path where the filesystem will be mounted. - - - - - Mount Log Level - handleMountInputChange("log_level", e.target.value)} - > - DEBUG (Verbose) - INFO (Standard) - NOTICE (Alerts) - ERROR (Critical) - - Verbosity of RClone mount logs. - - - - - - UID - - handleMountInputChange("uid", Number.parseInt(e.target.value, 10) || 1000) - } - placeholder="1000" - /> - User ID for files. - - - - GID - - handleMountInputChange("gid", Number.parseInt(e.target.value, 10) || 1000) - } - placeholder="1000" - /> - Group ID for files. - - - - Umask - handleMountInputChange("umask", e.target.value)} - placeholder="002" - /> - File permission mask. - - - - - Security & Flags - - - Allow Other - - Enable shared access - handleMountInputChange("allow_other", e.target.checked)} - /> - - - - - Allow Non-Empty - - Mount over files - - handleMountInputChange("allow_non_empty", e.target.checked) - } - /> - - - - - Read Only - - Disable writing - handleMountInputChange("read_only", e.target.checked)} - /> - - - - - - - VFS Cache Settings - - - Cache Mode - handleMountInputChange("vfs_cache_mode", e.target.value)} - > - off (No cache) - minimal (Metadata only) - writes (Only modified files) - full (Read & Write cache) - - Determines how much data RClone caches locally. - - - - Cache Directory - handleMountInputChange("cache_dir", e.target.value)} - placeholder="/config/cache" - /> - - Path for cached data (defaults to config/cache). - - - - - - - Max Cache Size - handleMountInputChange("vfs_cache_max_size", e.target.value)} - placeholder="50G" - /> - Maximum cache size (e.g., 50G, 1T). - - - - Cache Max Age - handleMountInputChange("vfs_cache_max_age", e.target.value)} - placeholder="504h" - /> - Maximum cache age (e.g., 504h, 7d). - - - - - - Cache Poll Interval - - handleMountInputChange("vfs_cache_poll_interval", e.target.value) - } - placeholder="1m" - /> - - Interval to poll for remote changes (e.g., 1m, 5s). - - - - - Read Ahead - handleMountInputChange("vfs_read_ahead", e.target.value)} - placeholder="128M" - /> - Read ahead size (e.g., 128M, 256M). - - - - - - Performance Settings - - - Read Chunk Size - - handleMountInputChange("vfs_read_chunk_size", e.target.value) - } - placeholder="32M" - /> - Initial read chunk size (e.g., 32M, 64M). - - - - Read Chunk Size Limit - - handleMountInputChange("vfs_read_chunk_size_limit", e.target.value) - } - placeholder="2G" - /> - Maximum read chunk size (e.g., 2G, 4G). - - - - - - Directory Cache Time - handleMountInputChange("dir_cache_time", e.target.value)} - placeholder="10m" - /> - Directory cache time (e.g., 10m, 1h). - - - - Transfers - - handleMountInputChange( - "transfers", - Number.parseInt(e.target.value, 10) || 4, - ) - } - min="1" - max="32" - /> - Number of parallel transfers (1-32). - - - - - - Advanced Flags - - - Async Read - - Enable async read operations - handleMountInputChange("async_read", e.target.checked)} - /> - - - - - No Mod Time - - Don't write mod time - handleMountInputChange("no_mod_time", e.target.checked)} - /> - - - - - - {/* Custom Mount Options */} - - Custom Mount Options - - Arbitrary flags to pass to the rclone mount command. (e.g.,{" "} - no-modtime: true) - - handleMountInputChange("mount_options", val)} - keyPlaceholder="Flag (e.g. no-modtime)" - valuePlaceholder="Value (e.g. true)" - /> - - - {/* Mount Status & Actions */} - - - {mountStatus && ( - - - - - {mountStatus.mounted ? "Mounted" : "Not Mounted"} - - {mountStatus.mounted && mountStatus.mount_point && ( - Mount point: {mountStatus.mount_point} - )} - {mountStatus.error && {mountStatus.error}} - - - {mountStatus.mounted ? ( - - {isMountLoading ? ( - - ) : ( - - )} - Stop Mount - - ) : ( - - {isMountLoading ? ( - - ) : ( - - )} - Start Mount - - )} - - - )} - - {!isReadOnly && ( - - - {isUpdating ? ( - - ) : ( - - )} - Save Mount Changes - - - )} - > - )} - - - {/* RC Configuration Section */} - - - - - Remote Control (RC) - - - - - - Enable RC Connection - - - Enable connection for cache refresh notifications - {mountFormData.mount_enabled && ( - Managed by mount - )} - - handleRCEnabledChange(e.target.checked)} - /> - - - - {(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"} - /> - setShowRCPassword(!showRCPassword)} - > - {showRCPassword ? ( - - ) : ( - - )} - - - - - - {/* 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 && ( - - - {isTestingConnection && } - Test Connection - - - {isUpdating && } - Save RC Changes - - - )} - > - )} - - - - {/* 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 && ( - - - Start Scan - - )} - - {(isScanning || isCanceling) && ( - - - {isCanceling ? "Canceling..." : "Cancel"} - - )} - - - - {/* 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 && ( - - - - Try Again - - - )} - - - ); -} 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 && ( - handleRemove(key)} - > - - - )} - - ))} - - - {!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 - - )} - @@ -163,9 +163,7 @@ function ConnectionPoolGrid({ used, max }: { used: number; max: number }) { ))} @@ -335,7 +333,7 @@ export function ProviderHealth() { 0 ? "animate-pulse text-primary shadow-[0_0_12px_rgba(59,130,246,0.3)]" : "opacity-45"}`} + className={`h-8 w-8 ${data.download_speed_bytes_per_sec > 0 ? "animate-pulse text-primary" : "opacity-45"}`} /> {/* Active wave line on bottom of the card */} @@ -410,7 +408,7 @@ export function ProviderHealth() {
No files tracked
- Manage the virtual mount and Remote Control (RC) interface. -
- {isMountToggleSaving - ? "Saving..." - : "Highly recommended for all-in-one Docker setups"} -
- Absolute path where the filesystem will be mounted. -
Verbosity of RClone mount logs.
User ID for files.
Group ID for files.
File permission mask.
Determines how much data RClone caches locally.
- Path for cached data (defaults to config/cache). -
Maximum cache size (e.g., 50G, 1T).
Maximum cache age (e.g., 504h, 7d).
- Interval to poll for remote changes (e.g., 1m, 5s). -
Read ahead size (e.g., 128M, 256M).
Initial read chunk size (e.g., 32M, 64M).
Maximum read chunk size (e.g., 2G, 4G).
Directory cache time (e.g., 10m, 1h).
Number of parallel transfers (1-32).
- Arbitrary flags to pass to the rclone mount command. (e.g.,{" "} - no-modtime: true) -
no-modtime: true
{validationError}
No active streams
{error.message}
- Match an *arr error message to an action: remove, blocklist, or blocklist + - search. + When a stuck import's error matches one of these, run the chosen action: remove, + blocklist, or blocklist + search.
- Automatically clears imports that get stuck for a known reason — runs on a - schedule or on demand from the Queue page. -
@@ -609,7 +462,7 @@ export function ArrsConfigSection({ blocklist, or blocklist + search.