From 20798b3dc0be727d7d602c0a83e5a53c05f761c8 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 17 Feb 2026 17:03:21 -0500 Subject: [PATCH] Add RDT-Client support and Prowlarr prompt Introduce RDT-Client integration and related UI/behavior changes. - Add RDTClientService extending QBittorrentService with RDT-specific behavior (stale-torrent deletion, postProcess cleanup, no-op categories). - Register 'rdtclient' in supported client types, display names, and protocol mapping; create RDT client factory in DownloadClientManager. - Add RDT-Client card to DownloadClientManagement UI and placeholder URL in DownloadClientModal. - Update qbittorrent service: omit per-torrent savepath/sequential options (favor category/automatic management), make several methods protected, and clean up related comments. - Make organize-files.processor treat rdtclient as a special-case for cleanup (remove local torrent entries after organize). - Add prowlarr service singleton invalidation and call it when Prowlarr settings are updated so background jobs pick up new credentials. - Add confirmation flow when changing Prowlarr URL/API key: new useIndexersSettings logic to detect credential changes, prompt ConfirmModal from IndexersTab, and optionally clear configured indexers on confirmed change. These changes ensure Real-Debrid-backed qBittorrent-compatible clients are supported correctly and that switching Prowlarr credentials is handled safely. --- src/app/admin/settings/page.tsx | 1 + .../settings/tabs/IndexersTab/IndexersTab.tsx | 32 +++++- .../tabs/IndexersTab/useIndexersSettings.ts | 81 ++++++++++++-- src/app/api/admin/settings/prowlarr/route.ts | 4 + .../DownloadClientManagement.tsx | 33 +++++- .../download-clients/DownloadClientModal.tsx | 2 +- src/lib/integrations/prowlarr.service.ts | 12 ++ src/lib/integrations/qbittorrent.service.ts | 38 +++---- src/lib/integrations/rdtclient.service.ts | 105 ++++++++++++++++++ .../interfaces/download-client.interface.ts | 4 +- .../processors/organize-files.processor.ts | 9 +- .../download-client-manager.service.ts | 26 +++++ 12 files changed, 308 insertions(+), 39 deletions(-) create mode 100644 src/lib/integrations/rdtclient.service.ts diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index dec2cd8..01b5416 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -295,6 +295,7 @@ export default function AdminSettings() { {activeTab === 'prowlarr' && ( void; @@ -27,6 +29,7 @@ interface IndexersTabProps { export function IndexersTab({ settings, + originalSettings, indexers, flagConfigs, onChange, @@ -35,11 +38,23 @@ export function IndexersTab({ onValidationChange, onRefreshIndexers, }: IndexersTabProps) { - const { testing, testResult, testConnection } = useIndexersSettings({ + const { + testing, + testResult, + testConnection, + showConnectionChangeConfirm, + confirmConnectionChange, + cancelConnectionChange, + configuredIndexersCount, + } = useIndexersSettings({ prowlarrUrl: settings.prowlarr.url, prowlarrApiKey: settings.prowlarr.apiKey, + originalProwlarrUrl: originalSettings?.prowlarr.url ?? '', + originalProwlarrApiKey: originalSettings?.prowlarr.apiKey ?? '', + configuredIndexersCount: indexers.length, onValidationChange, onRefreshIndexers, + onClearIndexers: () => onIndexersChange([]), }); // Auto-load indexers when component mounts if prowlarr is configured @@ -96,7 +111,7 @@ export function IndexersTab({ placeholder="Enter API key" />

- Found in Prowlarr Settings → General → Security → API Key + Found in Prowlarr Settings → General → Security → API Key

@@ -178,6 +193,19 @@ export function IndexersTab({

)} + + {/* Confirmation modal for Prowlarr connection change */} + ); } diff --git a/src/app/admin/settings/tabs/IndexersTab/useIndexersSettings.ts b/src/app/admin/settings/tabs/IndexersTab/useIndexersSettings.ts index 6e06444..f346974 100644 --- a/src/app/admin/settings/tabs/IndexersTab/useIndexersSettings.ts +++ b/src/app/admin/settings/tabs/IndexersTab/useIndexersSettings.ts @@ -5,30 +5,50 @@ 'use client'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { fetchWithAuth } from '@/lib/utils/api'; import type { TestResult } from '../../lib/types'; interface UseIndexersSettingsProps { prowlarrUrl: string; prowlarrApiKey: string; + originalProwlarrUrl: string; + originalProwlarrApiKey: string; + configuredIndexersCount: number; onValidationChange: (isValid: boolean) => void; onRefreshIndexers?: () => Promise; + onClearIndexers: () => void; } export function useIndexersSettings({ prowlarrUrl, prowlarrApiKey, + originalProwlarrUrl, + originalProwlarrApiKey, + configuredIndexersCount, onValidationChange, onRefreshIndexers, + onClearIndexers, }: UseIndexersSettingsProps) { const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); + const [showConnectionChangeConfirm, setShowConnectionChangeConfirm] = useState(false); /** - * Test Prowlarr connection + * Detect if the Prowlarr URL or API key has changed from the saved values. + * A masked API key (starting with dots) means the user hasn't touched it. */ - const testConnection = async () => { + const hasConnectionChanged = useCallback((): boolean => { + const urlChanged = prowlarrUrl.trim() !== originalProwlarrUrl.trim(); + const apiKeyChanged = !prowlarrApiKey.startsWith('••••') && + prowlarrApiKey !== originalProwlarrApiKey; + return urlChanged || apiKeyChanged; + }, [prowlarrUrl, prowlarrApiKey, originalProwlarrUrl, originalProwlarrApiKey]); + + /** + * Execute the actual Prowlarr connection test + */ + const executeTest = async (shouldClearIndexers: boolean) => { setTesting(true); setTestResult(null); @@ -46,14 +66,23 @@ export function useIndexersSettings({ if (data.success) { onValidationChange(true); - setTestResult({ - success: true, - message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`, - }); - // Refresh indexers from database if callback provided - if (onRefreshIndexers) { - await onRefreshIndexers(); + if (shouldClearIndexers) { + onClearIndexers(); + setTestResult({ + success: true, + message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers. Previous indexer configurations have been removed — please re-add indexers from the new instance.`, + }); + } else { + setTestResult({ + success: true, + message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`, + }); + + // Refresh indexers from database if callback provided + if (onRefreshIndexers) { + await onRefreshIndexers(); + } } } else { onValidationChange(false); @@ -74,9 +103,41 @@ export function useIndexersSettings({ } }; + /** + * Handle test connection click — shows confirmation if credentials changed + * and there are existing configured indexers. + */ + const testConnection = async () => { + if (hasConnectionChanged() && configuredIndexersCount > 0) { + setShowConnectionChangeConfirm(true); + return; + } + + await executeTest(false); + }; + + /** + * User confirmed the credential change — proceed with test and clear indexers on success + */ + const confirmConnectionChange = async () => { + setShowConnectionChangeConfirm(false); + await executeTest(true); + }; + + /** + * User cancelled the credential change confirmation + */ + const cancelConnectionChange = () => { + setShowConnectionChangeConfirm(false); + }; + return { testing, testResult, testConnection, + showConnectionChangeConfirm, + confirmConnectionChange, + cancelConnectionChange, + configuredIndexersCount, }; } diff --git a/src/app/api/admin/settings/prowlarr/route.ts b/src/app/api/admin/settings/prowlarr/route.ts index 946e0b3..d081a22 100644 --- a/src/app/api/admin/settings/prowlarr/route.ts +++ b/src/app/api/admin/settings/prowlarr/route.ts @@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { getEncryptionService } from '@/lib/services/encryption.service'; +import { invalidateProwlarrService } from '@/lib/integrations/prowlarr.service'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Admin.Settings.Prowlarr'); @@ -42,6 +43,9 @@ export async function PUT(request: NextRequest) { }); } + // Invalidate cached singleton so background jobs use new credentials + invalidateProwlarrService(); + logger.info('Prowlarr settings updated'); return NextResponse.json({ diff --git a/src/components/admin/download-clients/DownloadClientManagement.tsx b/src/components/admin/download-clients/DownloadClientManagement.tsx index c38f5c4..80062db 100644 --- a/src/components/admin/download-clients/DownloadClientManagement.tsx +++ b/src/components/admin/download-clients/DownloadClientManagement.tsx @@ -253,7 +253,7 @@ export function DownloadClientManagement({

Add Download Client

-
+
{/* qBittorrent Card */}
@@ -316,6 +316,37 @@ export function DownloadClientManagement({ )}
+ {/* RDT-Client Card */} +
+
+
+

+ RDT-Client +

+

+ Torrent / Debrid +

+
+ + Torrent + +
+ {hasTorrentClient ? ( +
+ Protocol already configured +
+ ) : ( + + )} +
+ {/* SABnzbd Card */}
diff --git a/src/components/admin/download-clients/DownloadClientModal.tsx b/src/components/admin/download-clients/DownloadClientModal.tsx index 6f05c51..020d1d4 100644 --- a/src/components/admin/download-clients/DownloadClientModal.tsx +++ b/src/components/admin/download-clients/DownloadClientModal.tsx @@ -338,7 +338,7 @@ export function DownloadClientModal({ setUrl(e.target.value)} - placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'} + placeholder={type === 'rdtclient' ? 'http://localhost:6500' : type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'} error={errors.url} />

diff --git a/src/lib/integrations/prowlarr.service.ts b/src/lib/integrations/prowlarr.service.ts index abb6e63..7532563 100644 --- a/src/lib/integrations/prowlarr.service.ts +++ b/src/lib/integrations/prowlarr.service.ts @@ -640,6 +640,18 @@ export class ProwlarrService { // Singleton instance let prowlarrService: ProwlarrService | null = null; +/** + * Invalidate the cached ProwlarrService singleton. + * Must be called after updating Prowlarr URL or API key so that + * background jobs (search, RSS monitor, etc.) pick up the new credentials. + */ +export function invalidateProwlarrService(): void { + if (prowlarrService) { + logger.info('Prowlarr service singleton invalidated — will reconnect with new credentials on next use'); + } + prowlarrService = null; +} + export async function getProwlarrService(): Promise { if (!prowlarrService) { // Get configuration from database diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 77aec95..0efb661 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -27,12 +27,10 @@ const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule; const logger = RMABLogger.create('QBittorrent'); export interface AddTorrentOptions { - savePath?: string; category?: string; tags?: string[]; paused?: boolean; skipChecking?: boolean; - sequentialDownload?: boolean; } export interface TorrentInfo { @@ -276,7 +274,7 @@ export class QBittorrentService implements IDownloadClient { /** * Add magnet link - hash is extractable from URI (deterministic) */ - private async addMagnetLink( + protected async addMagnetLink( magnetUrl: string, category: string, options?: AddTorrentOptions @@ -299,20 +297,18 @@ export class QBittorrentService implements IDownloadClient { // Torrent doesn't exist, continue with adding } - // Apply reverse path mapping (local → remote) to savepath - const localSavePath = options?.savePath || this.defaultSavePath; - const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig); - // Upload via 'urls' parameter - // Set ratioLimit and seedingTimeLimit to -1 (unlimited) so qBittorrent's + // Note: savepath is intentionally omitted — the category (managed by ensureCategory) + // defines the save path. Omitting per-torrent savepath allows qBittorrent to use + // Automatic Torrent Management, respecting the user's "incomplete downloads" temp folder. + // sequentialDownload is also omitted — left to qBittorrent's own settings. + // ratioLimit and seedingTimeLimit are set to -1 (unlimited) so qBittorrent's // global seeding rules don't remove the torrent prematurely. // RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor. const form = new URLSearchParams({ urls: magnetUrl, - savepath: remoteSavePath, category, paused: options?.paused ? 'true' : 'false', - sequentialDownload: (options?.sequentialDownload !== false).toString(), ratioLimit: '-1', seedingTimeLimit: '-1', }); @@ -341,7 +337,7 @@ export class QBittorrentService implements IDownloadClient { /** * Add .torrent file - download, parse, extract hash, upload content (deterministic) */ - private async addTorrentFile( + protected async addTorrentFile( torrentUrl: string, category: string, options?: AddTorrentOptions @@ -446,11 +442,13 @@ export class QBittorrentService implements IDownloadClient { // Torrent doesn't exist, continue with adding } - // Apply reverse path mapping (local → remote) to savepath - const localSavePath = options?.savePath || this.defaultSavePath; - const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig); - // Upload .torrent file content via multipart/form-data + // Note: savepath is intentionally omitted — the category (managed by ensureCategory) + // defines the save path. Omitting per-torrent savepath allows qBittorrent to use + // Automatic Torrent Management, respecting the user's "incomplete downloads" temp folder. + // sequentialDownload is also omitted — left to qBittorrent's own settings. + // ratioLimit and seedingTimeLimit override qBittorrent's global seeding rules — + // RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor. const formData = new FormData(); const filename = parsedTorrent.name ? `${parsedTorrent.name}.torrent` : 'torrent.torrent'; @@ -458,11 +456,8 @@ export class QBittorrentService implements IDownloadClient { filename, contentType: 'application/x-bittorrent', }); - formData.append('savepath', remoteSavePath); formData.append('category', category); formData.append('paused', options?.paused ? 'true' : 'false'); - formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString()); - // Override qBittorrent's global seeding rules — RMAB manages torrent lifecycle formData.append('ratioLimit', '-1'); formData.append('seedingTimeLimit', '-1'); @@ -494,7 +489,7 @@ export class QBittorrentService implements IDownloadClient { * Checks existing categories first, then creates or updates as needed * Applies reverse path mapping (local → remote) for remote seedbox scenarios */ - private async ensureCategory(category: string): Promise { + protected async ensureCategory(category: string): Promise { if (!this.cookie) { await this.login(); } @@ -1013,7 +1008,6 @@ export class QBittorrentService implements IDownloadClient { category: options?.category, paused: options?.paused, tags: ['audiobook'], - sequentialDownload: true, }); } @@ -1081,7 +1075,7 @@ export class QBittorrentService implements IDownloadClient { /** * Map a TorrentInfo object to the unified DownloadInfo format. */ - private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo { + protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo { return { id: torrent.hash, name: torrent.name, @@ -1194,7 +1188,7 @@ export class QBittorrentService implements IDownloadClient { /** * Extract info_hash from magnet link */ - private extractHashFromMagnet(magnetUrl: string): string | null { + protected extractHashFromMagnet(magnetUrl: string): string | null { // Extract hash from magnet:?xt=urn:btih:HASH const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i); if (match) { diff --git a/src/lib/integrations/rdtclient.service.ts b/src/lib/integrations/rdtclient.service.ts new file mode 100644 index 0000000..faf5935 --- /dev/null +++ b/src/lib/integrations/rdtclient.service.ts @@ -0,0 +1,105 @@ +/** + * Component: RDT-Client Integration Service + * Documentation: documentation/phase3/download-clients.md + * + * RDT-Client is a Real-Debrid torrent proxy that emulates the qBittorrent API. + * Extends QBittorrentService and overrides behavioral differences: + * - Duplicate detection: deletes stale torrent before adding fresh (no false matches) + * - postProcess: removes torrent entry from client after files are organized + * - ensureCategory: no-op (RDT-Client doesn't support categories) + */ + +import { RMABLogger } from '../utils/logger'; +import { DownloadClientType } from '../interfaces/download-client.interface'; +import { QBittorrentService, AddTorrentOptions } from './qbittorrent.service'; + +const logger = RMABLogger.create('RDTClient'); + +export class RDTClientService extends QBittorrentService { + override readonly clientType: DownloadClientType = 'rdtclient'; + + /** + * Override: Delete any existing torrent with the same hash before adding. + * RDT-Client can have stale entries from previous requests that cause + * false duplicate detection — always start fresh. + */ + protected override async addMagnetLink( + magnetUrl: string, + category: string, + options?: AddTorrentOptions + ): Promise { + const infoHash = this.extractHashFromMagnet(magnetUrl); + + if (infoHash) { + await this.deleteStaleIfExists(infoHash); + } + + return super.addMagnetLink(magnetUrl, category, options); + } + + /** + * Override: Delete any existing torrent with the same hash before adding. + * Same rationale as addMagnetLink — prevent false duplicate short-circuits. + */ + protected override async addTorrentFile( + torrentUrl: string, + category: string, + options?: AddTorrentOptions + ): Promise { + // We can't pre-extract the hash from a .torrent URL without downloading it, + // so we let the parent handle the full flow. The parent's duplicate check + // calls getTorrent which will find any stale entry — but the parent + // short-circuits on duplicates. To handle this, we override addTorrentFile + // to intercept after the parent downloads and parses the torrent. + // + // The parent's addTorrentFile downloads the .torrent, parses it, checks for + // duplicates, then uploads. Since we can't hook into the middle of that flow + // without duplicating the download logic, we accept that .torrent file adds + // may encounter a stale duplicate. The primary use case (magnet links from + // indexers) is handled by the addMagnetLink override above. + // + // For .torrent files, the parent will return the existing hash if a duplicate + // is found. The postProcess cleanup after organize will still clean it up. + return super.addTorrentFile(torrentUrl, category, options); + } + + /** + * Override: Remove torrent entry from RDT-Client after files are organized. + * Unlike qBittorrent (which seeds), RDT-Client torrents should be cleaned up + * immediately — Real-Debrid handles seeding on their infrastructure. + */ + override async postProcess(id: string): Promise { + try { + logger.info(`Removing torrent ${id} from RDT-Client (post-organize cleanup)`); + await this.deleteTorrent(id, false); + logger.info(`Successfully removed torrent ${id} from RDT-Client`); + } catch (error) { + // Non-fatal: torrent may already have been removed + logger.warn( + `Failed to remove torrent ${id} from RDT-Client: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + /** + * Override: No-op. RDT-Client doesn't support qBittorrent categories. + * Avoids 404 errors that appear in logs when the parent tries to create/update categories. + */ + protected override async ensureCategory(_category: string): Promise { + // No-op: RDT-Client does not support categories + } + + /** + * Delete a stale torrent if it exists, so a fresh add doesn't short-circuit. + */ + private async deleteStaleIfExists(hash: string): Promise { + try { + await this.getTorrent(hash); + // If we get here, torrent exists — delete it + logger.info(`Deleting stale torrent ${hash} from RDT-Client before fresh add`); + await this.deleteTorrent(hash, false); + } catch { + // Torrent doesn't exist — nothing to clean up + } + } +} diff --git a/src/lib/interfaces/download-client.interface.ts b/src/lib/interfaces/download-client.interface.ts index 8ac47a6..2e5f619 100644 --- a/src/lib/interfaces/download-client.interface.ts +++ b/src/lib/interfaces/download-client.interface.ts @@ -11,7 +11,7 @@ // ========================================================================= /** Supported download client types — single source of truth */ -export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const; +export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission', 'rdtclient'] as const; /** Identifies the specific download client software */ export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number]; @@ -22,6 +22,7 @@ export const CLIENT_DISPLAY_NAMES: Record = { sabnzbd: 'SABnzbd', nzbget: 'NZBGet', transmission: 'Transmission', + rdtclient: 'RDT-Client', }; /** Get display name for a client type, falling back to the raw type */ @@ -38,6 +39,7 @@ export const CLIENT_PROTOCOL_MAP: Record = { sabnzbd: 'usenet', nzbget: 'usenet', transmission: 'torrent', + rdtclient: 'torrent', }; /** Unified download status across all clients */ diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index a27ce1b..42daedf 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -864,8 +864,13 @@ async function cleanupDownloadAfterOrganize( removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined', }); - // Check if this is a non-torrent indexer with cleanup enabled - if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) { + // Check if this is a non-torrent indexer with cleanup enabled. + // RDT-Client is an exception: even though it's torrent protocol, it needs cleanup + // because Real-Debrid handles seeding — local torrent entries should be removed. + const isRDTClient = downloadHistory.downloadClient === 'rdtclient'; + const isTorrentProtocol = indexer?.protocol?.toLowerCase() === 'torrent'; + + if (!indexer || (!isRDTClient && isTorrentProtocol) || !indexer.removeAfterProcessing) { return; } diff --git a/src/lib/services/download-client-manager.service.ts b/src/lib/services/download-client-manager.service.ts index 11693ba..2118cae 100644 --- a/src/lib/services/download-client-manager.service.ts +++ b/src/lib/services/download-client-manager.service.ts @@ -16,6 +16,7 @@ import { QBittorrentService } from '@/lib/integrations/qbittorrent.service'; import { SABnzbdService } from '@/lib/integrations/sabnzbd.service'; import { NZBGetService } from '@/lib/integrations/nzbget.service'; import { TransmissionService } from '@/lib/integrations/transmission.service'; +import { RDTClientService } from '@/lib/integrations/rdtclient.service'; import { PathMappingConfig } from '@/lib/utils/path-mapper'; import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface'; @@ -193,6 +194,8 @@ export class DownloadClientManager { return this.createNZBGetService(config, downloadDir); case 'transmission': return this.createTransmissionService(config, downloadDir); + case 'rdtclient': + return this.createRDTClientService(config, downloadDir); default: throw new Error(`Unsupported download client type: ${config.type}`); } @@ -335,6 +338,29 @@ export class DownloadClientManager { ); } + /** + * Create RDT-Client service instance (same constructor as qBittorrent — identical API) + */ + private createRDTClientService(config: DownloadClientConfig, downloadDir: string): RDTClientService { + const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath + ? { + enabled: true, + remotePath: config.remotePath, + localPath: config.localPath, + } + : undefined; + + return new RDTClientService( + config.url, + config.username || '', + config.password || '', + downloadDir, + config.category || 'readmeabook', + config.disableSSLVerify, + pathMapping + ); + } + /** * Migrate legacy single-client config to new multi-client format */