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.
This commit is contained in:
kikootwo
2026-02-17 17:03:21 -05:00
parent 3f8180a246
commit 20798b3dc0
12 changed files with 308 additions and 39 deletions
+12
View File
@@ -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<ProwlarrService> {
if (!prowlarrService) {
// Get configuration from database
+16 -22
View File
@@ -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<void> {
protected async ensureCategory(category: string): Promise<void> {
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) {
+105
View File
@@ -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<string> {
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<string> {
// 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<void> {
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<void> {
// 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<void> {
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
}
}
}
@@ -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<DownloadClientType, string> = {
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<DownloadClientType, ProtocolType> = {
sabnzbd: 'usenet',
nzbget: 'usenet',
transmission: 'torrent',
rdtclient: 'torrent',
};
/** Unified download status across all clients */
@@ -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;
}
@@ -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
*/