/** * 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} /> )}
); }