mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-18 04:00:10 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user