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
*/