From ba99d1d5b6125f284c6b1b637f22b7dd44d0f863 Mon Sep 17 00:00:00 2001 From: drondeseries Date: Mon, 18 May 2026 14:30:10 -0400 Subject: [PATCH] fix(importer): consolidate ARR queue stability and health-aware deletion fixes This comprehensive set of fixes resolves long-standing issues with Sonarr/Radarr queue synchronization and accidental file deletion: ### 1. ARR Queue Stability and Visibility - State Synchronization: Refactored History API to proactively surface completed items from the active import queue. This prevents a race condition where successfully imported files vanish from Sonarr's sight because they haven't yet been persisted to the ImportHistory database. - Improved Deduplication: Added deduplication logic in handleSABnzbdHistory to handle the overlap between active queue items and historical records. - Queue Limit Logic: Fixed SABnzbd Queue API limit handling. - ARR Queue Cleanup: Added a proactive removal trigger for successfully imported items via webhook, and implemented a more robust polling mechanism in the post-processor to ensure ARR applications only get notifications when files are fully visible on the VFS mount. ### 2. Health-Aware Deletion Guard - Race Condition Resolution: Resolved a bug in the Redundant Deletion Guard where it blindly honored ARR EpisodeFileDelete webhooks even for files that had just been successfully verified as healthy. - Health-Aware Logic: Updated the guard in arrs_handlers.go to query the healthRepo before proceeding. Deletion is now skipped if the file is confirmed Healthy and was recently imported (within 5 minutes). - Grace Period: This prevents stale webhook events from previous failed import attempts (common during high-frequency retries) from deleting successfully imported media. ### 3. Stability & API Refinements - Improved ID Persistence: Ensured DownloadID is correctly captured and persisted into ImportHistory. - API Compatibility: Cleaned up the SABnzbd History API, decoupling it from the active queue while ensuring consistency with Sonarr's expectations. --- config.sample.yaml | 7 +- .../components/config/MountConfigSection.tsx | 23 +- .../components/config/RCloneConfigSection.tsx | 1609 ++++++++--------- .../config/SABnzbdConfigSection.tsx | 18 + .../config/StreamingConfigSection.tsx | 103 +- .../components/config/SystemConfigSection.tsx | 40 + frontend/src/components/ui/KeyValueEditor.tsx | 102 ++ .../ProviderHealth/ProviderHealth.tsx | 1 + frontend/src/types/config.ts | 38 +- internal/api/arrs_handlers.go | 95 +- internal/api/sabnzbd_handlers.go | 48 +- internal/api/types.go | 76 +- internal/arrs/clients/manager.go | 14 +- internal/arrs/instances/manager.go | 6 +- internal/arrs/model/metadata.go | 30 + internal/arrs/scanner/discovery.go | 14 +- internal/arrs/scanner/manager.go | 170 +- internal/arrs/worker/worker.go | 17 +- internal/config/manager.go | 28 +- internal/database/health_repository.go | 33 +- internal/database/repository.go | 10 + .../importer/postprocessor/coordinator.go | 40 +- internal/importer/processor.go | 20 +- internal/importer/service.go | 4 +- pkg/rclonecli/client.go | 8 + pkg/rclonecli/manager.go | 10 + 26 files changed, 1568 insertions(+), 996 deletions(-) create mode 100644 frontend/src/components/ui/KeyValueEditor.tsx diff --git a/config.sample.yaml b/config.sample.yaml index aace5c77..af56fa2f 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -91,8 +91,9 @@ rclone: vfs_cache_mode: 'full' # VFS cache mode: off|minimal|writes|full (--vfs-cache-mode=full) vfs_cache_max_size: '50G' # Maximum cache size (--vfs-cache-max-size=50G) vfs_cache_max_age: '504h' # Maximum cache age (--vfs-cache-max-age=504h, 21 days) - read_chunk_size: '32M' # VFS read chunk size (--vfs-read-chunk-size=32M) - read_chunk_size_limit: '2G' # Read chunk size limit (--vfs-read-chunk-size-limit=2G) + vfs_cache_poll_interval: '1m' # How often to poll for remote changes (--vfs-cache-poll-interval=1m) + vfs_read_chunk_size: '32M' # VFS read chunk size (--vfs-read-chunk-size=32M) + vfs_read_chunk_size_limit: '2G' # Read chunk size limit (--vfs-read-chunk-size-limit=2G) vfs_read_ahead: '128M' # VFS read-ahead size (--vfs-read-ahead=128M) dir_cache_time: '10m' # Directory cache time (--dir-cache-time=10m) @@ -153,14 +154,12 @@ import: - '.pdf' - '.cbz' max_import_connections: 5 # Number of concurrent NNTP connections for validation and archive processing - import_cache_size_mb: 64 # Cache size in MB for archive analysis segment_sample_percentage: 1 # Percentage of segments to sample for validation (1-100) import_strategy: 'NONE' # Import strategy: NONE (direct import), SYMLINK (create symlinks), STRM (create .strm files) # NOTE: SYMLINK on Windows requires `allow_symlinks_on_windows: true` AND Windows Developer Mode enabled. Otherwise use STRM. import_dir: '' # Import directory (required when import_strategy is SYMLINK or STRM, must be absolute path) # Windows example: 'C:\Users\user\Videos' allow_symlinks_on_windows: false # Permit symlink creation on Windows (requires Developer Mode in Windows Settings → For developers). No effect on Linux/macOS. Default: false. - skip_health_check: true # Bypass Usenet article validation during import (default: true) failed_item_retention_hours: 24 # Auto-remove failed queue items and NZB files after this many hours (0 to disable, default: 24) # Health monitoring configuration diff --git a/frontend/src/components/config/MountConfigSection.tsx b/frontend/src/components/config/MountConfigSection.tsx index 17ad61ec..849db583 100644 --- a/frontend/src/components/config/MountConfigSection.tsx +++ b/frontend/src/components/config/MountConfigSection.tsx @@ -798,6 +798,16 @@ function RCloneMountSubSection({ config, onFormDataChange }: RCloneSubSectionPro placeholder="50G" /> +
+ Cache Poll Interval + handleMountInputChange("vfs_cache_poll_interval", e.target.value)} + placeholder="1m" + /> +
Cache Max Age @@ -845,8 +855,8 @@ function RCloneMountSubSection({ config, onFormDataChange }: RCloneSubSectionPro handleMountInputChange("read_chunk_size", e.target.value)} + value={mountFormData.vfs_read_chunk_size} + onChange={(e) => handleMountInputChange("vfs_read_chunk_size", e.target.value)} placeholder="32M" />
@@ -855,8 +865,8 @@ function RCloneMountSubSection({ config, onFormDataChange }: RCloneSubSectionPro handleMountInputChange("read_chunk_size_limit", e.target.value)} + value={mountFormData.vfs_read_chunk_size_limit} + onChange={(e) => handleMountInputChange("vfs_read_chunk_size_limit", e.target.value)} placeholder="2G" /> @@ -1324,10 +1334,11 @@ function buildRCloneMountFormData(config: ConfigResponse): RCloneMountFormData { transfers: config.rclone.transfers || 4, cache_dir: config.rclone.cache_dir || "", vfs_cache_mode: config.rclone.vfs_cache_mode || "full", + vfs_cache_poll_interval: config.rclone.vfs_cache_poll_interval || "1m", vfs_cache_max_size: config.rclone.vfs_cache_max_size || "50G", vfs_cache_max_age: config.rclone.vfs_cache_max_age || "504h", - read_chunk_size: config.rclone.read_chunk_size || "32M", - read_chunk_size_limit: config.rclone.read_chunk_size_limit || "2G", + 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", diff --git a/frontend/src/components/config/RCloneConfigSection.tsx b/frontend/src/components/config/RCloneConfigSection.tsx index f416a581..a9a0231c 100644 --- a/frontend/src/components/config/RCloneConfigSection.tsx +++ b/frontend/src/components/config/RCloneConfigSection.tsx @@ -1,4 +1,13 @@ -import { Eye, EyeOff, HardDrive, Play, Save, Square, TestTube } from "lucide-react"; +import { KeyValueEditor } from "../ui/KeyValueEditor"; +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"; @@ -64,8 +73,9 @@ export function RCloneConfigSection({ 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", - read_chunk_size: config.rclone.read_chunk_size || "32M", - read_chunk_size_limit: config.rclone.read_chunk_size_limit || "2G", + 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", @@ -82,7 +92,9 @@ export function RCloneConfigSection({ }); // Separate state for mount path since it's a root-level config - const [mountPath, setMountPath] = useState(config.mount_path || "/mnt/remotes/altmount"); + const [mountPath, setMountPath] = useState( + config.mount_path || "/mnt/remotes/altmount", + ); const [mountStatus, setMountStatus] = useState(null); const [hasChanges, setHasChanges] = useState(false); @@ -139,8 +151,9 @@ export function RCloneConfigSection({ 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", - read_chunk_size: config.rclone.read_chunk_size || "32M", - read_chunk_size_limit: config.rclone.read_chunk_size_limit || "2G", + 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", @@ -158,39 +171,37 @@ export function RCloneConfigSection({ setMountFormData(newMountFormData); setHasMountChanges(false); - // Sync mount path setMountPath(config.mount_path || "/mnt/remotes/altmount"); setHasMountPathChanges(false); - }, [config.rclone, config.mount_path]); + }, [config]); const fetchMountStatus = useCallback(async () => { try { - const response = await fetch("/api/rclone/mount/status"); - const result = await response.json(); - if (result.success && result.data) { - setMountStatus(result.data); + 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); } }, []); - // Fetch mount status on component mount and when mount is enabled useEffect(() => { - if (config.rclone.mount_enabled) { - fetchMountStatus(); - } - }, [config.rclone.mount_enabled, fetchMountStatus]); + fetchMountStatus(); + const interval = setInterval(fetchMountStatus, 5000); + return () => clearInterval(interval); + }, [fetchMountStatus]); const handleInputChange = ( field: keyof RCloneRCFormData, - value: string | boolean | number | Record, + value: string | number | boolean | Record, ) => { - const newData = { ...formData, [field]: value }; - setFormData(newData); + const newFormData = { ...formData, [field]: value }; + setFormData(newFormData); - // Check for changes by comparing against original config - const configData = { + // 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", @@ -199,274 +210,173 @@ export function RCloneConfigSection({ rc_pass: "", rc_options: config.rclone.rc_options, }; - - // Always consider changes if RC password is entered - const rcPasswordChanged = newData.rc_pass !== ""; - const otherFieldsChanged = - newData.rc_enabled !== configData.rc_enabled || - newData.rc_url !== configData.rc_url || - newData.vfs_name !== configData.vfs_name || - newData.rc_port !== configData.rc_port || - newData.rc_user !== configData.rc_user || - JSON.stringify(newData.rc_options) !== JSON.stringify(configData.rc_options); - - setHasChanges(rcPasswordChanged || otherFieldsChanged); + setHasChanges( + JSON.stringify(newFormData) !== JSON.stringify(initialFormData), + ); }; const handleMountInputChange = ( field: keyof RCloneMountFormData, - value: string | boolean | number | Record, + value: string | number | boolean | Record, ) => { - const newData = { ...mountFormData, [field]: value }; - setMountFormData(newData); + const newMountFormData = { ...mountFormData, [field]: value }; + setMountFormData(newMountFormData); - // Always mark as changed when any field is modified - setHasMountChanges(true); + // 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 || {}, - // Note: RC is automatically managed by the backend when mount is enabled - // No need to manually enable RC here + // 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 || "/mnt/remotes/altmount")); + setHasMountPathChanges(value !== config.mount_path); }; const handleSave = async () => { if (onUpdate && hasChanges) { - // Only send non-empty values for RC password - const updateData: RCloneRCFormData = { - rc_enabled: formData.rc_enabled ?? false, - rc_url: formData.rc_url || "", - vfs_name: formData.vfs_name || "altmount", - rc_port: formData.rc_port || 5572, - rc_user: formData.rc_user || "", - rc_pass: formData.rc_pass.trim() !== "" ? formData.rc_pass : "", - rc_options: formData.rc_options || {}, - }; - - await onUpdate("rclone", updateData); + const saveDelta: RCloneRCFormData = { ...formData }; + // Don't send empty password if it hasn't changed + if (saveDelta.rc_pass === "") { + delete (saveDelta as any).rc_pass; + } + await onUpdate("rclone", saveDelta as any); setHasChanges(false); } }; - // Handle RC enabled toggle with auto-save - const handleRCEnabledChange = async (enabled: boolean) => { - // Don't allow changes if mount is enabled (RC is managed by mount) - if (mountFormData.mount_enabled) return; - - // Update local state immediately for UI responsiveness - setFormData((prev) => ({ ...prev, rc_enabled: enabled })); - - if (!onUpdate) return; - - setIsRCToggleSaving(true); - try { - // Save the RC enabled state immediately - const updateData: RCloneRCFormData = { - rc_enabled: enabled, - rc_url: formData.rc_url || "", - vfs_name: formData.vfs_name || "altmount", - rc_port: formData.rc_port || 5572, - rc_user: formData.rc_user || "", - rc_pass: "", // Don't send password on toggle - rc_options: formData.rc_options || {}, - }; - - await onUpdate("rclone", updateData); - - // Clear the hasChanges flag since we just saved - setHasChanges(false); - - showToast({ - type: "success", - title: enabled ? "RC enabled" : "RC disabled", - message: `RClone Remote Control has been ${enabled ? "enabled" : "disabled"} successfully`, - }); - } catch (error) { - // Revert the state on error - setFormData((prev) => ({ ...prev, rc_enabled: !enabled })); - - showToast({ - type: "error", - title: `Failed to ${enabled ? "enable" : "disable"} RC`, - message: error instanceof Error ? error.message : "Unknown error occurred", - }); - } finally { - setIsRCToggleSaving(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, + } as any); + setHasMountChanges(false); + setHasMountPathChanges(false); } }; - const handleSaveMount = async () => { + const handleRCEnabledChange = async (enabled: boolean) => { if (onUpdate) { - // When mount is enabled and we have any changes, send them together to avoid validation errors - if (mountFormData.mount_enabled && (hasMountChanges || hasMountPathChanges)) { - // Create a combined payload for the parent to handle - // This ensures both mount settings and path are validated together - await onUpdate("rclone_with_path", { - rclone: mountFormData, - mount_path: mountPath, - }); - setHasMountChanges(false); - setHasMountPathChanges(false); - } else { - // Handle separate updates when mount is disabled - if (hasMountChanges) { - await onUpdate("rclone", mountFormData); - setHasMountChanges(false); - } - - if (hasMountPathChanges) { - await onUpdate("mount_path", { mount_path: mountPath }); - setHasMountPathChanges(false); - } - } - - // Refresh mount status after saving - if (mountFormData.mount_enabled) { - // Set loading state while refreshing mount status - setIsMountLoading(true); - try { - await fetchMountStatus(); - } finally { - setIsMountLoading(false); - } + setIsRCToggleSaving(true); + try { + await onUpdate("rclone", { rc_enabled: enabled } as any); + } finally { + setIsRCToggleSaving(false); } } }; - // Handle mount enabled toggle with auto-save const handleMountEnabledChange = async (enabled: boolean) => { - // If disabling mount and there's an active mount, show confirmation dialog - if (!enabled && mountStatus?.mounted) { - const confirmed = await confirmAction( - "Disable Mount", - `The mount is currently active". Disabling the mount will stop the active mount and unmount the filesystem. Do you want to continue?`, - { - type: "warning", - confirmText: "Disable & Unmount", - confirmButtonClass: "btn-warning", - }, - ); - - if (!confirmed) { - // User cancelled - revert the checkbox state - return; + if (onUpdate) { + setIsMountToggleSaving(true); + try { + await onUpdate("rclone", { mount_enabled: enabled } as any); + } finally { + setIsMountToggleSaving(false); } } + }; - // Set loading state for both enabling and disabling - setIsMountToggleSaving(true); - - // Update local state immediately for UI responsiveness - setMountFormData((prev) => ({ ...prev, mount_enabled: enabled })); - - // When enabling mount, don't auto-save - let user configure path first - if (enabled) { - // Mark that there are unsaved mount changes - setHasMountChanges(true); - - showToast({ - type: "info", - title: "Mount enabled", - message: "Please configure the mount path and save your changes", - }); - - // Clear loading state after showing message - setIsMountToggleSaving(false); - return; - } - - // Only auto-save when disabling the mount - if (!onUpdate) { - setIsMountToggleSaving(false); - return; - } + const handleTestConnection = async () => { + setIsTestingConnection(true); + setTestResult(null); try { - // If disabling and mount is active, stop the mount first - if (!enabled && mountStatus?.mounted) { - try { - const response = await fetch("/api/rclone/mount/stop", { - method: "POST", - }); - const result = await response.json(); - - if (!result.success) { - throw new Error(result.message || "Failed to stop mount"); - } - - // Update mount status to reflect stopped state - setMountStatus({ mounted: false, mount_point: "" }); - - showToast({ - type: "success", - title: "Mount stopped", - message: "Active mount has been stopped successfully", - }); - } catch (stopError) { - // Revert state and show error - setMountFormData((prev) => ({ ...prev, mount_enabled: true })); - - showToast({ - type: "error", - title: "Failed to stop mount", - message: stopError instanceof Error ? stopError.message : "Unknown error occurred", - }); - return; - } - } - - // Save the mount disabled state - await onUpdate("rclone", { ...mountFormData, mount_enabled: false }); - - // Clear the hasMountChanges flag since we just saved - setHasMountChanges(false); - - showToast({ - type: "success", - title: "Mount disabled", - message: "RClone mount has been disabled successfully", + const response = await fetch("/api/mount/test-rc", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), }); - } catch (error) { - // Revert the state on error - setMountFormData((prev) => ({ ...prev, mount_enabled: true })); - - showToast({ - type: "error", - title: "Failed to disable mount", - message: error instanceof Error ? error.message : "Unknown error occurred", + 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 { - setIsMountToggleSaving(false); + 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/rclone/mount/start", { - method: "POST", - }); - const result = await response.json(); - if (result.success) { - setMountStatus(result.data); + const response = await fetch("/api/mount/start", { method: "POST" }); + if (response.ok) { showToast({ type: "success", - title: "Mount started", - message: "RClone mount has been started successfully", + title: "Mount Started", + message: "RClone mount initiated successfully", }); + fetchMountStatus(); } else { + const errorData = await response.json(); showToast({ type: "error", - title: "Failed to start mount", - message: result.message || "Unknown error occurred", + title: "Mount Failed", + message: errorData.error?.message || "Failed to start mount", }); } - } catch (error) { + } catch (_error) { showToast({ type: "error", - title: "Error starting mount", - message: error instanceof Error ? error.message : "Unknown error occurred", + title: "Error", + message: "Failed to communicate with API", }); } finally { setIsMountLoading(false); @@ -474,703 +384,670 @@ export function RCloneConfigSection({ }; 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/rclone/mount/stop", { - method: "POST", - }); - const result = await response.json(); - if (result.success) { - setMountStatus({ mounted: false, mount_point: "" }); + const response = await fetch("/api/mount/stop", { method: "POST" }); + if (response.ok) { showToast({ - type: "success", - title: "Mount stopped", - message: "RClone mount has been stopped successfully", + type: "info", + title: "Mount Stopped", + message: "RClone mount stopped successfully", }); + fetchMountStatus(); } else { + const errorData = await response.json(); showToast({ type: "error", - title: "Failed to stop mount", - message: result.message || "Unknown error occurred", + title: "Stop Failed", + message: errorData.error?.message || "Failed to stop mount", }); } - } catch (error) { + } catch (_error) { showToast({ type: "error", - title: "Error stopping mount", - message: error instanceof Error ? error.message : "Unknown error occurred", + title: "Error", + message: "Failed to communicate with API", }); } finally { setIsMountLoading(false); } }; - const handleTestConnection = async () => { - setIsTestingConnection(true); - setTestResult(null); - - try { - const response = await fetch("/api/rclone/test", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - rc_enabled: formData.rc_enabled, - rc_url: formData.rc_url, - vfs_name: formData.vfs_name, - rc_port: formData.rc_port, - rc_user: formData.rc_user, - rc_pass: formData.rc_pass, - rc_options: formData.rc_options, - mount_enabled: mountFormData.mount_enabled, - mount_options: mountFormData.mount_options, - }), - }); - - const result = await response.json(); - - if (result.success && result.data) { - if (result.data.success) { - setTestResult({ - success: true, - message: "Connection successful! RClone RC is accessible.", - }); - } else { - setTestResult({ - success: false, - message: result.data.error_message || "Connection failed", - }); - } - } else { - setTestResult({ - success: false, - message: result.message || "Test failed", - }); - } - } catch (error) { - setTestResult({ - success: false, - message: error instanceof Error ? error.message : "Network error occurred", - }); - } finally { - setIsTestingConnection(false); - } - }; - return ( -
-

RClone Configuration

- - {/* Mount Configuration Section */} -
-

Mount Configuration

- -
- Enable RClone Mount - -

- Mount the AltMount WebDAV as a local filesystem using RClone - {isMountToggleSaving && " (Saving...)"} -

- {mountFormData.mount_enabled && hasMountChanges && ( -
-
-
Configuration not saved!
-

- Please configure the mount path below and click "Save Mount Changes" to apply your - settings. -

-
-
- )} - {mountFormData.mount_enabled && ( -
-
-
Mount service will automatically:
-
    -
  • Start and manage the RC server on port 5572
  • -
  • Configure all necessary RC settings
  • -
  • Handle mount operations and cache management
  • -
-
-
- )} -
+
+
+

+ RClone Filesystem +

+

+ Manage the virtual mount and Remote Control (RC) interface. +

+
- {mountFormData.mount_enabled && ( - <> -
- Mount Point +
+ {/* Mount Configuration Section */} +
+
+ +

+ Mount Configuration +

+
+
+ +
+ Enable Internal Mount +
- - {/* Basic Mount Settings */} -
-
Basic Mount Settings
- -
-
- Allow Other Users - -

Enables --allow-other for FUSE mount

-
- -
- Allow Non-Empty - -

Enables --allow-non-empty for mounting

-
-
- -
-
- Read Only - -

Prevents write operations to the mount

-
+ +

+ {isMountToggleSaving + ? "Saving..." + : "Highly recommended for all-in-one Docker setups"} +

+
-
- Enable Syslog - -

Enables --syslog for system logging

-
-
+ {mountFormData.mount_enabled && ( + <> +
-
+
- Timeout + Mount Point Path handleMountInputChange("timeout", e.target.value)} - placeholder="10m" + onChange={(e) => handleMountPathChange(e.target.value)} + placeholder="/mnt/remotes/altmount" /> -

I/O timeout for mount operations (e.g., 10m, 30s)

+

+ Absolute path where the filesystem will be mounted. +

- Log Level + Mount Log Level -

Log level for rclone operations

+

Verbosity of RClone mount logs.

-
+
- User ID (UID) + UID - handleMountInputChange("uid", Number.parseInt(e.target.value, 10) || 1000) + handleMountInputChange( + "uid", + Number.parseInt(e.target.value, 10) || 1000, + ) } - min="0" - max="65535" + placeholder="1000" /> -

User ID for file ownership (default: 1000)

+

User ID for files.

- Group ID (GID) + GID - handleMountInputChange("gid", Number.parseInt(e.target.value, 10) || 1000) + handleMountInputChange( + "gid", + Number.parseInt(e.target.value, 10) || 1000, + ) } - min="0" - max="65535" - /> -

Group ID for file ownership (default: 1000)

-
-
-
- - {/* VFS Cache Settings */} -
-
VFS Cache Settings
- -
- Cache Directory - handleMountInputChange("cache_dir", e.target.value)} - placeholder="Defaults to /cache (e.g., /config/cache)" - /> -

- Directory for VFS cache storage (leave empty to use default location) -

-
- -
-
- Cache Mode - -

- VFS cache mode: full recommended for best performance -

-
- -
- Cache Max 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)

-
-
- - {/* Performance Settings */} -
-
Performance Settings
- -
-
- Buffer Size - handleMountInputChange("buffer_size", e.target.value)} - placeholder="32M" - /> -

Buffer size for file operations (e.g., 32M, 64M)

-
- -
- VFS Read Ahead - handleMountInputChange("vfs_read_ahead", e.target.value)} - placeholder="128M" - /> -

VFS read-ahead size (e.g., 128M, 256M)

-
-
- -
-
- Read Chunk Size - handleMountInputChange("read_chunk_size", e.target.value)} - placeholder="32M" + placeholder="1000" /> -

VFS read chunk size (e.g., 32M, 64M)

+

Group ID for files.

- Read Chunk Size Limit + Umask - handleMountInputChange("read_chunk_size_limit", e.target.value) + handleMountInputChange("umask", e.target.value) } - placeholder="2G" + placeholder="002" /> -

Maximum read chunk size (e.g., 2G, 4G)

+

File permission mask.

-
-
- 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)

-
+
+
+ Security & Flags +
+
+
+ Allow Other + +
+ +
+ Allow Non-Empty + +
+ +
+ Read Only + +
+
-
- {/* Advanced Settings */} -
-
Advanced Settings
+
+
+ VFS Cache Settings +
+
+
+ Cache Mode + +

+ 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). +

+
-
-
- Async Read -
+
-
- No Checksum -
+ +
+ Read Ahead + + handleMountInputChange("vfs_read_ahead", e.target.value) + } + placeholder="128M" + /> +

+ Read ahead size (e.g., 128M, 256M). +

+
+
-
-
- No Mod Time -
+ +
+ + Read Chunk Size Limit + + + handleMountInputChange( + "vfs_read_chunk_size_limit", + e.target.value, + ) + } + placeholder="2G" + /> +

+ Maximum read chunk size (e.g., 2G, 4G). +

+
+
-
- VFS Fast Fingerprint -
+ +
+ Transfers + + handleMountInputChange( + "transfers", + Number.parseInt(e.target.value, 10) || 4, + ) + } + min="1" + max="32" + /> +

+ Number of parallel transfers (1-32). +

+
+
-
- - {/* Mount Status */} - {mountStatus && ( -
- -
-
{mountStatus.mounted ? "Mounted" : "Not Mounted"}
- {mountStatus.mounted && mountStatus.mount_point && ( -
Mount point: {mountStatus.mount_point}
- )} - {mountStatus.error &&
{mountStatus.error}
} + +
+
+ Advanced Flags +
+
+
+ Async Read + +
+ +
+ No Mod Time + +
- {mountStatus.mounted ? ( -
+ + {/* 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 ? "Stopping..." : "Stop Mount"} - - ) : ( +
+
+ )} + + {!isReadOnly && ( +
- )} -
- )} - - {/* Action Buttons */} - {!isReadOnly && ( -
- -
- )} - - )} -
- - {/* RC Configuration Settings */} -
-

RC (Remote Control) Settings

- -
- Enable RC Connection -
)} - - handleRCEnabledChange(e.target.checked)} - /> - -

- {mountFormData.mount_enabled - ? "RC server is automatically managed by the mount service" - : isRCToggleSaving - ? "Saving..." - : "Enable connection to RClone RC server for cache refresh notifications"} -

- {mountFormData.mount_enabled && ( -
- - Mount service automatically starts and manages the RC server on port 5572 - - - RC configuration below is read-only when mount is enabled - -
+ )} - +
- {(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 (leave empty to start internal RC server)" - } - /> -

- {mountFormData.mount_enabled - ? "Using internal RC server managed by mount service" - : "External RClone RC server URL (leave empty to use internal RC server)"} -

-
- -
- VFS Name - handleInputChange("vfs_name", e.target.value)} - placeholder="altmount" - /> -

- Name of the VFS in RClone (default: altmount). Change this if your external rclone - mount uses a different name. -

-
- -
- RC Port + {/* RC Configuration Section */} +
+
+ +

+ Remote Control (RC) +

+
+
+ +
+ Enable RC Connection +
+ +
- {!mountFormData.mount_enabled && ( - <> + {(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)} - placeholder="admin" /> -

Username for RClone RC API authentication

@@ -1180,17 +1057,15 @@ export function RCloneConfigSection({ type={showRCPassword ? "text" : "password"} className="input pr-10" value={formData.rc_pass} - disabled={isReadOnly} + disabled={isReadOnly || mountFormData.mount_enabled} onChange={(e) => handleInputChange("rc_pass", e.target.value)} placeholder={ - config.rclone.rc_pass_set - ? "RC password is set (enter new to change)" - : "admin" + config.rclone.rc_pass_set ? "********" : "admin" } />
-

- Password for RClone RC API authentication - {config.rclone.rc_pass_set && " (currently set)"} -

- - )} +
- {!isReadOnly && ( -
- {!mountFormData.mount_enabled && ( - <> - - - - )} + {/* Custom RC Options */} +
+
+ Custom RC Options +
+ handleInputChange("rc_options", val)} + keyPlaceholder="Option (e.g. rc-web-gui)" + valuePlaceholder="Value (e.g. true)" + />
- )} - - )} + + {!isReadOnly && !mountFormData.mount_enabled && ( +
+ + +
+ )} + + )} +
{/* Test Result Alert */} {testResult && ( -
-
- {testResult.message} -
+
+ {testResult.message}
)}
diff --git a/frontend/src/components/config/SABnzbdConfigSection.tsx b/frontend/src/components/config/SABnzbdConfigSection.tsx index 777054b0..45ae0723 100644 --- a/frontend/src/components/config/SABnzbdConfigSection.tsx +++ b/frontend/src/components/config/SABnzbdConfigSection.tsx @@ -244,6 +244,24 @@ export function SABnzbdConfigSection({ The URL ARR instances use to reach this API.

+ +
+ History Retention (minutes) + + updateFormData({ + history_retention_minutes: Number.parseInt(e.target.value, 10) || 0, + }) + } + /> +

+ How far back the emulated history goes when polled by Arrs. +

+
diff --git a/frontend/src/components/config/StreamingConfigSection.tsx b/frontend/src/components/config/StreamingConfigSection.tsx index ec83dd43..5de487c1 100644 --- a/frontend/src/components/config/StreamingConfigSection.tsx +++ b/frontend/src/components/config/StreamingConfigSection.tsx @@ -1,6 +1,11 @@ import { Info, Save } from "lucide-react"; import { useEffect, useState } from "react"; -import type { ConfigResponse, SegmentCacheConfig, StreamingConfig } from "../../types/config"; +import type { + ConfigResponse, + FailureMaskingConfig, + SegmentCacheConfig, + StreamingConfig, +} from "../../types/config"; interface StreamingConfigSectionProps { config: ConfigResponse; @@ -38,6 +43,18 @@ export function StreamingConfigSection({ checkChanges(newData, cacheData); }; + const handleMaskingChange = (field: keyof FailureMaskingConfig, value: boolean | number) => { + const newData = { + ...streamingData, + failure_masking: { + ...streamingData.failure_masking, + [field]: value, + }, + }; + setStreamingData(newData); + checkChanges(newData, cacheData); + }; + const handleCacheChange = (field: keyof SegmentCacheConfig, value: boolean | string | number) => { const newData = { ...cacheData, [field]: value }; setCacheData(newData); @@ -126,6 +143,90 @@ export function StreamingConfigSection({ + {/* Failure Masking */} +
+

Failure Masking

+

+ Automatically hide files from the mount if they fail to stream too many times. +

+
+ +
+ {/* Masking Toggle */} +
+
+

Enable Masking

+

+ When enabled, files that repeatedly fail health checks while streaming are hidden. +

+
+ handleMaskingChange("enabled", e.target.checked)} + /> +
+ + {/* Threshold Slider */} +
+
+
+

Failure Threshold

+

+ Number of failures before the file is hidden from the mount. +

+
+
+ + {streamingData.failure_masking.threshold} + + failures +
+
+ +
+ + handleMaskingChange("threshold", Number.parseInt(e.target.value, 10)) + } + /> +
+ 1 + 3 + 5 + 7 + 10 +
+
+
+ + {/* Guidance */} +
+ +
+
+ Repair Workflow +
+
+ Masking a file makes it appear as "missing" to your mount. This triggers Sonarr or + Radarr to attempt a repair or redownload. The threshold protects against one-off + network glitches. +
+
+
+
+ {/* Segment Cache */}

Segment Cache

diff --git a/frontend/src/components/config/SystemConfigSection.tsx b/frontend/src/components/config/SystemConfigSection.tsx index ead539c1..38dba439 100644 --- a/frontend/src/components/config/SystemConfigSection.tsx +++ b/frontend/src/components/config/SystemConfigSection.tsx @@ -1,6 +1,7 @@ import { Check, Copy, + HardDrive, Monitor, Palette, RefreshCw, @@ -382,6 +383,45 @@ export function SystemConfigSection({
+ {/* Database Storage */} +
+
+ +

+ Database Storage +

+
+
+ +
+
+ Database Type + +
+ +
+ Connection DSN + +
+
+
+ {/* Performance Profiler */}
diff --git a/frontend/src/components/ui/KeyValueEditor.tsx b/frontend/src/components/ui/KeyValueEditor.tsx new file mode 100644 index 00000000..82dcc8b3 --- /dev/null +++ b/frontend/src/components/ui/KeyValueEditor.tsx @@ -0,0 +1,102 @@ +import { Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; + +interface KeyValueEditorProps { + value: Record; + onChange: (value: Record) => void; + keyPlaceholder?: string; + valuePlaceholder?: string; + disabled?: boolean; +} + +export function KeyValueEditor({ + value, + onChange, + keyPlaceholder = "Key", + valuePlaceholder = "Value", + disabled = false, +}: KeyValueEditorProps) { + const [newKey, setNewKey] = useState(""); + const [newValue, setNewValue] = useState(""); + + const handleAdd = () => { + if (!newKey.trim()) return; + const updated = { ...value, [newKey.trim()]: newValue.trim() }; + onChange(updated); + setNewKey(""); + setNewValue(""); + }; + + const handleRemove = (key: string) => { + const updated = { ...value }; + delete updated[key]; + onChange(updated); + }; + + const handleValueChange = (key: string, val: string) => { + const updated = { ...value, [key]: val }; + onChange(updated); + }; + + return ( +
+
+ {Object.entries(value).map(([key, val]) => ( +
+ + handleValueChange(key, e.target.value)} + /> + {!disabled && ( + + )} +
+ ))} +
+ + {!disabled && ( +
+ setNewKey(e.target.value)} + /> + setNewValue(e.target.value)} + /> + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderHealth.tsx b/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderHealth.tsx index 5c51c30e..98e77552 100644 --- a/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderHealth.tsx +++ b/frontend/src/pages/HealthPage/components/ProviderHealth/ProviderHealth.tsx @@ -411,6 +411,7 @@ export function ProviderHealth() {