From ade12cb82db3dc7a20a9eaaa20b3dbedfeb3d2a3 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 21 Apr 2026 01:56:39 -0400 Subject: [PATCH] Add Path Mapping Helper page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new client-side Path Mapping Helper page at src/app/path-helper/page.tsx. Implements a multi-step wizard to help users configure Docker volume mappings for download clients and ReadMeABook (RMAB): select clients, enter container save paths, enter host/container volume mappings (with optional remote path mapping), and generate recommended RMAB docker-compose volume snippet. Includes utility functions to compute common roots and relative paths, UI components (step indicator, info/warning boxes, code block), and logic to derive RMAB download directory, per-client custom paths, and verification instructions. No API calls — purely client-side helper with sensible defaults for supported clients. --- src/app/path-helper/page.tsx | 932 +++++++++++++++++++++++++++++++++++ 1 file changed, 932 insertions(+) create mode 100644 src/app/path-helper/page.tsx diff --git a/src/app/path-helper/page.tsx b/src/app/path-helper/page.tsx new file mode 100644 index 0000000..cbab4e3 --- /dev/null +++ b/src/app/path-helper/page.tsx @@ -0,0 +1,932 @@ +/** + * Component: Path Mapping Helper + * Documentation: documentation/deployment/volume-mapping.md + * + * Public, unprotected page that guides users through configuring + * Docker volume mappings for their download clients and RMAB. + * Purely client-side — no API calls, no real data access. + */ + +'use client'; + +import { useState, useMemo } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { + CLIENT_DISPLAY_NAMES, + CLIENT_PROTOCOL_MAP, + type DownloadClientType, +} from '@/lib/interfaces/download-client.interface'; + +// ========================================================================= +// TYPES +// ========================================================================= + +interface ClientConfig { + type: DownloadClientType; + /** The path inside the download client container where completed downloads land */ + savePath: string; + /** The volume mapping from the client's docker-compose (host:container) — host side */ + hostPath: string; + /** The volume mapping from the client's docker-compose (host:container) — container side */ + containerMountPath: string; + /** Whether this client needs remote path mapping */ + remotePathMapping: boolean; + /** The path as seen by the remote download client (for remote path mapping) */ + remotePath: string; +} + +type Step = 'clients' | 'save-paths' | 'host-paths' | 'results'; + +const STEPS: { key: Step; title: string }[] = [ + { key: 'clients', title: 'Clients' }, + { key: 'save-paths', title: 'Save Paths' }, + { key: 'host-paths', title: 'Volume Mapping' }, + { key: 'results', title: 'Results' }, +]; + +const ALL_CLIENTS: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'sabnzbd', 'nzbget']; + +const DEFAULT_SAVE_PATHS: Record = { + qbittorrent: '/downloads', + transmission: '/downloads/complete', + deluge: '/downloads', + sabnzbd: '/downloads/complete', + nzbget: '/downloads/completed', +}; + +// ========================================================================= +// UTILITY FUNCTIONS +// ========================================================================= + +/** + * Find the longest common path prefix across multiple paths. + * Only meaningful when there are multiple DIFFERENT paths. + */ +function findCommonRoot(paths: string[]): string { + if (paths.length === 0) return ''; + if (paths.length === 1) return paths[0]; + + const unique = [...new Set(paths)]; + if (unique.length === 1) return unique[0]; + + // Split each path into segments + const segmentArrays = unique.map((p) => p.replace(/\/+$/, '').split('/').filter(Boolean)); + const minLength = Math.min(...segmentArrays.map((s) => s.length)); + + const commonSegments: string[] = []; + for (let i = 0; i < minLength; i++) { + const segment = segmentArrays[0][i]; + if (segmentArrays.every((s) => s[i] === segment)) { + commonSegments.push(segment); + } else { + break; + } + } + + if (commonSegments.length === 0) return '/'; + return '/' + commonSegments.join('/'); +} + +/** + * Get the relative path from a root to a full path. + * Returns empty string if they're the same. + */ +function getRelativePath(root: string, fullPath: string): string { + const normalizedRoot = root.replace(/\/+$/, ''); + const normalizedFull = fullPath.replace(/\/+$/, ''); + + if (normalizedRoot === normalizedFull) return ''; + + if (normalizedFull.startsWith(normalizedRoot + '/')) { + return normalizedFull.slice(normalizedRoot.length + 1); + } + + // Shouldn't happen if common root is correct, but fallback + return normalizedFull; +} + +/** + * Find the common root of the host paths to build the RMAB volume mapping. + * Maps from the host path hierarchy to the container path hierarchy. + */ +function findHostCommonRoot(configs: ClientConfig[]): string { + const hostPaths = configs.map((c) => c.hostPath); + if (hostPaths.length === 0) return ''; + if (hostPaths.length === 1) return hostPaths[0]; + + const unique = [...new Set(hostPaths)]; + if (unique.length === 1) return unique[0]; + + return findCommonRoot(hostPaths); +} + +// ========================================================================= +// COMPONENTS +// ========================================================================= + +function StepIndicator({ currentStep }: { currentStep: Step }) { + const currentIndex = STEPS.findIndex((s) => s.key === currentStep); + + return ( +
+ {STEPS.map((step, index) => ( +
+
+
+ {index < currentIndex ? ( + + + + ) : ( + index + 1 + )} +
+ + {step.title} + +
+ {index < STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ ); +} + +function InfoBox({ children }: { children: React.ReactNode }) { + return ( +
+
+ + + +
{children}
+
+
+ ); +} + +function WarningBox({ children }: { children: React.ReactNode }) { + return ( +
+
+ + + +
{children}
+
+
+ ); +} + +function CodeBlock({ children, label, onCopy }: { children: string; label?: string; onCopy?: () => void }) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(children); + setCopied(true); + onCopy?.(); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {label && ( +
{label}
+ )} +
+
{children}
+
+ +
+ ); +} + +// ========================================================================= +// STEP COMPONENTS +// ========================================================================= + +function ClientSelectionStep({ + selectedClients, + onToggle, + onNext, +}: { + selectedClients: Set; + onToggle: (client: DownloadClientType) => void; + onNext: () => void; +}) { + return ( +
+
+

+ Which download clients do you use? +

+

+ Select all the download clients you have configured or plan to use with ReadMeABook. +

+
+ +
+ {ALL_CLIENTS.map((client) => { + const protocol = CLIENT_PROTOCOL_MAP[client]; + const isSelected = selectedClients.has(client); + + return ( + + ); + })} +
+ +
+ +
+
+ ); +} + +function SavePathsStep({ + configs, + onUpdateConfig, + onNext, + onBack, +}: { + configs: ClientConfig[]; + onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string) => void; + onNext: () => void; + onBack: () => void; +}) { + const allFilled = configs.every((c) => c.savePath.trim() !== ''); + + return ( +
+
+

+ Download client save paths +

+

+ For each client, enter the path inside that client's container where + completed downloads are saved. This is the path you see in the client's own settings + (e.g., qBittorrent Web UI → Options → Downloads → Default Save Path). +

+
+ + +

+ This is the container path, not the host path. For example, if your + qBittorrent docker-compose has - + /mnt/data/torrents:/downloads, and qBittorrent is configured to save + to /downloads, then + enter /downloads here. +

+
+ +
+ {configs.map((config) => ( +
+
+ + {CLIENT_DISPLAY_NAMES[config.type]} + + + {CLIENT_PROTOCOL_MAP[config.type]} + +
+ onUpdateConfig(config.type, 'savePath', e.target.value)} + className="font-mono" + helperText={`Default: ${DEFAULT_SAVE_PATHS[config.type]}`} + /> +
+ ))} +
+ +
+ + +
+
+ ); +} + +function HostPathsStep({ + configs, + onUpdateConfig, + onNext, + onBack, +}: { + configs: ClientConfig[]; + onUpdateConfig: (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => void; + onNext: () => void; + onBack: () => void; +}) { + const allFilled = configs.every( + (c) => c.hostPath.trim() !== '' && c.containerMountPath.trim() !== '' && (!c.remotePathMapping || c.remotePath.trim() !== '') + ); + + return ( +
+
+

+ Docker volume mappings +

+

+ For each client, enter the volume mapping from that client's docker-compose + file. This tells us where on your host machine the downloads actually end up. +

+
+ + +

+ A Docker volume mapping looks like /host/path:/container/path in + your docker-compose.yml. We need both sides so we know how to map RMAB to the same files. +

+
+ +
+ {configs.map((config) => ( +
+
+ + {CLIENT_DISPLAY_NAMES[config.type]} + + + {CLIENT_PROTOCOL_MAP[config.type]} + +
+ +
+ onUpdateConfig(config.type, 'hostPath', e.target.value)} + className="font-mono" + helperText="The real path on your server" + /> + onUpdateConfig(config.type, 'containerMountPath', e.target.value)} + className="font-mono" + helperText="The path inside the container" + /> +
+ + {config.containerMountPath && config.hostPath && ( +
+ {config.hostPath}:{config.containerMountPath} +
+ )} + + {/* Remote path mapping toggle */} +
+
+ onUpdateConfig(config.type, 'remotePathMapping', e.target.checked)} + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ Enable this if the download client is on a seedbox, separate server, or otherwise has a + different filesystem than where RMAB runs. Also enable this if the client runs on the + host (not in Docker) while RMAB runs in Docker. +

+
+
+ + {config.remotePathMapping && ( +
+ onUpdateConfig(config.type, 'remotePath', e.target.value)} + className="font-mono" + helperText="The path the download client reports when a download completes. This is often the same as the client's save path." + /> +
+ )} +
+
+ ))} +
+ +
+ + +
+
+ ); +} + +function ResultsStep({ + configs, + onBack, + onRestart, +}: { + configs: ClientConfig[]; + onBack: () => void; + onRestart: () => void; +}) { + // Determine if we need custom paths (multiple clients with different save paths) + const savePaths = configs.map((c) => c.savePath.replace(/\/+$/, '')); + const uniqueSavePaths = [...new Set(savePaths)]; + const needsCustomPaths = configs.length > 1 && uniqueSavePaths.length > 1; + + // Calculate RMAB download directory + const rmabDownloadDir = needsCustomPaths ? findCommonRoot(savePaths) : savePaths[0]; + + // Calculate custom paths per client (only if needed) + const clientCustomPaths = needsCustomPaths + ? configs.map((c) => ({ + type: c.type, + customPath: getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')), + })) + : []; + + // Calculate RMAB volume mapping + // We need the host path that corresponds to the rmabDownloadDir + // If all clients share the same save path, we use that client's host path directly. + // If multiple different paths, we find the common host root. + let rmabHostPath: string; + let rmabContainerPath: string; + + if (!needsCustomPaths) { + // Single path scenario — use the first client's host path + // But we need to consider if the container mount path differs from the save path + const config = configs[0]; + const saveRelativeToMount = getRelativePath( + config.containerMountPath.replace(/\/+$/, ''), + config.savePath.replace(/\/+$/, '') + ); + + if (saveRelativeToMount) { + // Save path is deeper than the mount: host must include that extra depth + rmabHostPath = config.hostPath.replace(/\/+$/, '') + '/' + saveRelativeToMount; + } else { + rmabHostPath = config.hostPath; + } + rmabContainerPath = rmabDownloadDir; + } else { + // Multiple different paths — we need to find the host root that covers all + // For each client, compute the host path that corresponds to the common container root + const hostRoots = configs.map((c) => { + const mountRelativeToCommon = getRelativePath( + rmabDownloadDir, + c.containerMountPath.replace(/\/+$/, '') + ); + const saveRelativeToMount = getRelativePath( + c.containerMountPath.replace(/\/+$/, ''), + c.savePath.replace(/\/+$/, '') + ); + // The host path maps to containerMountPath. We need to go up if rmabDownloadDir + // is a parent of the container mount path. + const containerMountNorm = c.containerMountPath.replace(/\/+$/, ''); + const rmabDirNorm = rmabDownloadDir.replace(/\/+$/, ''); + + if (containerMountNorm === rmabDirNorm) { + return c.hostPath.replace(/\/+$/, ''); + } else if (containerMountNorm.startsWith(rmabDirNorm + '/')) { + // Container mount is deeper than RMAB dir — we need to go up on the host side + const depth = containerMountNorm.slice(rmabDirNorm.length + 1).split('/').length; + const hostSegments = c.hostPath.replace(/\/+$/, '').split('/'); + return hostSegments.slice(0, -depth).join('/') || '/'; + } else if (rmabDirNorm.startsWith(containerMountNorm + '/')) { + // RMAB dir is deeper than container mount — append the extra to host + const extra = rmabDirNorm.slice(containerMountNorm.length + 1); + return c.hostPath.replace(/\/+$/, '') + '/' + extra; + } + return c.hostPath.replace(/\/+$/, ''); + }); + + rmabHostPath = findHostCommonRoot( + configs.map((c, i) => ({ ...c, hostPath: hostRoots[i] })) + ); + rmabContainerPath = rmabDownloadDir; + } + + // Build the RMAB compose snippet + const composeSnippet = `services: + readmeabook: + volumes: + - ${rmabHostPath}:${rmabContainerPath} + # ... your other RMAB volumes (config, media, etc.)`; + + // Build remote path mapping info + const remoteClients = configs.filter((c) => c.remotePathMapping); + + return ( +
+
+

+ Your recommended configuration +

+

+ Based on your inputs, here's how to configure ReadMeABook and your download clients. +

+
+ + {/* RMAB Download Directory */} +
+

+ 1. RMAB Download Directory Setting +

+

+ Set this in RMAB's settings under Admin → Settings → Paths → Download Directory. +

+ {rmabDownloadDir} +
+ + {/* Custom paths per client */} + {needsCustomPaths && clientCustomPaths.some((c) => c.customPath) && ( +
+

+ 2. Client Custom Paths +

+

+ Since your clients save to different locations, set these custom paths on each download client + in RMAB (Admin → Settings → Download Clients → Edit → Custom Path). +

+
+ {clientCustomPaths.map((c) => ( +
+ + {CLIENT_DISPLAY_NAMES[c.type as DownloadClientType]}: + + + {c.customPath || '(none — same as download directory)'} + +
+ ))} +
+
+ )} + + {/* RMAB Docker Compose Volume */} +
+

+ {needsCustomPaths ? '3' : '2'}. RMAB Docker Compose Volume Mapping +

+

+ Add this volume mapping to your RMAB docker-compose.yml. This ensures RMAB can see the + same files your download clients produce. +

+ {composeSnippet} +
+ + {/* Golden Rule explanation */} + +

The Golden Rule

+

+ Both your download client and RMAB must see files at the same container path. + The volume mapping above ensures that when your download client saves a file + to {configs[0]?.savePath}, + RMAB can also find it at that same path. +

+
+ + {/* Verification */} +
+

+ {needsCustomPaths ? '4' : '3'}. Verify your setup +

+
+
    + {configs.map((c) => ( +
  • + + + {CLIENT_DISPLAY_NAMES[c.type]} saves + to {c.savePath} + {' '}→ host path {c.hostPath} + {needsCustomPaths && ( + <> + {' '}→ RMAB custom + path: + {getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')) || '(none)'} + + + )} + +
  • + ))} +
  • + + + RMAB mounts {rmabHostPath}:{rmabContainerPath} + {' '}→ download directory set + to {rmabDownloadDir} + +
  • +
+
+
+ + {/* Remote Path Mapping */} + {remoteClients.length > 0 && ( +
+

+ Remote Path Mapping +

+

+ These clients run on a different machine. Configure remote path mapping for each in + RMAB (Admin → Settings → Download Clients → Edit). +

+
+ {remoteClients.map((c) => { + const localPath = needsCustomPaths + ? rmabDownloadDir + '/' + getRelativePath(rmabDownloadDir, c.savePath.replace(/\/+$/, '')) + : rmabDownloadDir; + + return ( +
+
+ {CLIENT_DISPLAY_NAMES[c.type]} +
+
+
+ Enable Remote Path Mapping: + Yes +
+
+ Remote Path: + {c.remotePath} +
+
+ Local Path: + {localPath} +
+
+ +

+ When this client reports a file at {c.remotePath}/audiobook.m4b, + RMAB will translate it to {localPath}/audiobook.m4b. +

+
+
+ ); + })} +
+
+ )} + +
+ + +
+
+ ); +} + +// ========================================================================= +// MAIN PAGE +// ========================================================================= + +export default function PathHelperPage() { + const [step, setStep] = useState('clients'); + const [selectedClients, setSelectedClients] = useState>(new Set()); + const [clientConfigs, setClientConfigs] = useState>(new Map()); + + // Build ordered configs array from selected clients + const configs = useMemo(() => { + return ALL_CLIENTS + .filter((c) => selectedClients.has(c)) + .map((type) => { + const existing = clientConfigs.get(type); + return ( + existing || { + type, + savePath: DEFAULT_SAVE_PATHS[type], + hostPath: '', + containerMountPath: '', + remotePathMapping: false, + remotePath: '', + } + ); + }); + }, [selectedClients, clientConfigs]); + + const toggleClient = (client: DownloadClientType) => { + setSelectedClients((prev) => { + const next = new Set(prev); + if (next.has(client)) { + next.delete(client); + } else { + next.add(client); + // Initialize config if not exists + if (!clientConfigs.has(client)) { + setClientConfigs((prev) => { + const next = new Map(prev); + next.set(client, { + type: client, + savePath: DEFAULT_SAVE_PATHS[client], + hostPath: '', + containerMountPath: '', + remotePathMapping: false, + remotePath: '', + }); + return next; + }); + } + } + return next; + }); + }; + + const updateConfig = (type: DownloadClientType, field: keyof ClientConfig, value: string | boolean) => { + setClientConfigs((prev) => { + const next = new Map(prev); + const existing = next.get(type); + if (existing) { + next.set(type, { ...existing, [field]: value }); + } + return next; + }); + }; + + const goToStep = (target: Step) => setStep(target); + + const restart = () => { + setStep('clients'); + setSelectedClients(new Set()); + setClientConfigs(new Map()); + }; + + return ( +
+ {/* Header */} +
+
+

+ Path Mapping Helper +

+

+ Get your download client volume mappings configured correctly for ReadMeABook +

+
+
+ + {/* Step Indicator */} +
+
+ +
+
+ + {/* Main Content */} +
+
+ {step === 'clients' && ( + goToStep('save-paths')} + /> + )} + {step === 'save-paths' && ( + goToStep('host-paths')} + onBack={() => goToStep('clients')} + /> + )} + {step === 'host-paths' && ( + goToStep('results')} + onBack={() => goToStep('save-paths')} + /> + )} + {step === 'results' && ( + goToStep('host-paths')} + onRestart={restart} + /> + )} +
+
+
+ ); +}