mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +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:
@@ -295,6 +295,7 @@ export default function AdminSettings() {
|
|||||||
{activeTab === 'prowlarr' && (
|
{activeTab === 'prowlarr' && (
|
||||||
<IndexersTab
|
<IndexersTab
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
originalSettings={originalSettings}
|
||||||
indexers={configuredIndexers}
|
indexers={configuredIndexers}
|
||||||
flagConfigs={flagConfigs}
|
flagConfigs={flagConfigs}
|
||||||
onChange={setSettings}
|
onChange={setSettings}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||||
@@ -16,6 +17,7 @@ import type { Settings, SavedIndexerConfig } from '../../lib/types';
|
|||||||
|
|
||||||
interface IndexersTabProps {
|
interface IndexersTabProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
|
originalSettings: Settings | null;
|
||||||
indexers: SavedIndexerConfig[];
|
indexers: SavedIndexerConfig[];
|
||||||
flagConfigs: IndexerFlagConfig[];
|
flagConfigs: IndexerFlagConfig[];
|
||||||
onChange: (settings: Settings) => void;
|
onChange: (settings: Settings) => void;
|
||||||
@@ -27,6 +29,7 @@ interface IndexersTabProps {
|
|||||||
|
|
||||||
export function IndexersTab({
|
export function IndexersTab({
|
||||||
settings,
|
settings,
|
||||||
|
originalSettings,
|
||||||
indexers,
|
indexers,
|
||||||
flagConfigs,
|
flagConfigs,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -35,11 +38,23 @@ export function IndexersTab({
|
|||||||
onValidationChange,
|
onValidationChange,
|
||||||
onRefreshIndexers,
|
onRefreshIndexers,
|
||||||
}: IndexersTabProps) {
|
}: IndexersTabProps) {
|
||||||
const { testing, testResult, testConnection } = useIndexersSettings({
|
const {
|
||||||
|
testing,
|
||||||
|
testResult,
|
||||||
|
testConnection,
|
||||||
|
showConnectionChangeConfirm,
|
||||||
|
confirmConnectionChange,
|
||||||
|
cancelConnectionChange,
|
||||||
|
configuredIndexersCount,
|
||||||
|
} = useIndexersSettings({
|
||||||
prowlarrUrl: settings.prowlarr.url,
|
prowlarrUrl: settings.prowlarr.url,
|
||||||
prowlarrApiKey: settings.prowlarr.apiKey,
|
prowlarrApiKey: settings.prowlarr.apiKey,
|
||||||
|
originalProwlarrUrl: originalSettings?.prowlarr.url ?? '',
|
||||||
|
originalProwlarrApiKey: originalSettings?.prowlarr.apiKey ?? '',
|
||||||
|
configuredIndexersCount: indexers.length,
|
||||||
onValidationChange,
|
onValidationChange,
|
||||||
onRefreshIndexers,
|
onRefreshIndexers,
|
||||||
|
onClearIndexers: () => onIndexersChange([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-load indexers when component mounts if prowlarr is configured
|
// Auto-load indexers when component mounts if prowlarr is configured
|
||||||
@@ -96,7 +111,7 @@ export function IndexersTab({
|
|||||||
placeholder="Enter API key"
|
placeholder="Enter API key"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Found in Prowlarr Settings → General → Security → API Key
|
Found in Prowlarr Settings → General → Security → API Key
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,6 +193,19 @@ export function IndexersTab({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation modal for Prowlarr connection change */}
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showConnectionChangeConfirm}
|
||||||
|
onClose={cancelConnectionChange}
|
||||||
|
onConfirm={confirmConnectionChange}
|
||||||
|
title="Prowlarr Connection Change"
|
||||||
|
message={`Changing your Prowlarr connection will remove your ${configuredIndexersCount} configured indexer${configuredIndexersCount === 1 ? '' : 's'}. Indexer IDs are specific to each Prowlarr instance, so existing configurations cannot be preserved. You will need to re-add indexers from the new instance after saving.`}
|
||||||
|
confirmText="Continue"
|
||||||
|
cancelText="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={testing}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,30 +5,50 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { fetchWithAuth } from '@/lib/utils/api';
|
import { fetchWithAuth } from '@/lib/utils/api';
|
||||||
import type { TestResult } from '../../lib/types';
|
import type { TestResult } from '../../lib/types';
|
||||||
|
|
||||||
interface UseIndexersSettingsProps {
|
interface UseIndexersSettingsProps {
|
||||||
prowlarrUrl: string;
|
prowlarrUrl: string;
|
||||||
prowlarrApiKey: string;
|
prowlarrApiKey: string;
|
||||||
|
originalProwlarrUrl: string;
|
||||||
|
originalProwlarrApiKey: string;
|
||||||
|
configuredIndexersCount: number;
|
||||||
onValidationChange: (isValid: boolean) => void;
|
onValidationChange: (isValid: boolean) => void;
|
||||||
onRefreshIndexers?: () => Promise<void>;
|
onRefreshIndexers?: () => Promise<void>;
|
||||||
|
onClearIndexers: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIndexersSettings({
|
export function useIndexersSettings({
|
||||||
prowlarrUrl,
|
prowlarrUrl,
|
||||||
prowlarrApiKey,
|
prowlarrApiKey,
|
||||||
|
originalProwlarrUrl,
|
||||||
|
originalProwlarrApiKey,
|
||||||
|
configuredIndexersCount,
|
||||||
onValidationChange,
|
onValidationChange,
|
||||||
onRefreshIndexers,
|
onRefreshIndexers,
|
||||||
|
onClearIndexers,
|
||||||
}: UseIndexersSettingsProps) {
|
}: UseIndexersSettingsProps) {
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
const [testResult, setTestResult] = useState<TestResult | null>(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);
|
setTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
@@ -46,14 +66,23 @@ export function useIndexersSettings({
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
onValidationChange(true);
|
onValidationChange(true);
|
||||||
setTestResult({
|
|
||||||
success: true,
|
|
||||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh indexers from database if callback provided
|
if (shouldClearIndexers) {
|
||||||
if (onRefreshIndexers) {
|
onClearIndexers();
|
||||||
await onRefreshIndexers();
|
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 {
|
} else {
|
||||||
onValidationChange(false);
|
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 {
|
return {
|
||||||
testing,
|
testing,
|
||||||
testResult,
|
testResult,
|
||||||
testConnection,
|
testConnection,
|
||||||
|
showConnectionChangeConfirm,
|
||||||
|
confirmConnectionChange,
|
||||||
|
cancelConnectionChange,
|
||||||
|
configuredIndexersCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
import { getEncryptionService } from '@/lib/services/encryption.service';
|
import { getEncryptionService } from '@/lib/services/encryption.service';
|
||||||
|
import { invalidateProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||||
import { RMABLogger } from '@/lib/utils/logger';
|
import { RMABLogger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const logger = RMABLogger.create('API.Admin.Settings.Prowlarr');
|
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');
|
logger.info('Prowlarr settings updated');
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export function DownloadClientManagement({
|
|||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||||
Add Download Client
|
Add Download Client
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
{/* qBittorrent Card */}
|
{/* qBittorrent Card */}
|
||||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
@@ -316,6 +316,37 @@ export function DownloadClientManagement({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* RDT-Client Card */}
|
||||||
|
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasTorrentClient ? ' opacity-50' : ''}`}>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||||
|
RDT-Client
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Torrent / Debrid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-block text-xs px-2 py-1 rounded bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium">
|
||||||
|
Torrent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasTorrentClient ? (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Protocol already configured
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAddClient('rdtclient')}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Add RDT-Client
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* SABnzbd Card */}
|
{/* SABnzbd Card */}
|
||||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
|
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6${hasUsenetClient ? ' opacity-50' : ''}`}>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export function DownloadClientModal({
|
|||||||
<Input
|
<Input
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => 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}
|
error={errors.url}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
@@ -640,6 +640,18 @@ export class ProwlarrService {
|
|||||||
// Singleton instance
|
// Singleton instance
|
||||||
let prowlarrService: ProwlarrService | null = null;
|
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> {
|
export async function getProwlarrService(): Promise<ProwlarrService> {
|
||||||
if (!prowlarrService) {
|
if (!prowlarrService) {
|
||||||
// Get configuration from database
|
// Get configuration from database
|
||||||
|
|||||||
@@ -27,12 +27,10 @@ const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
|
|||||||
const logger = RMABLogger.create('QBittorrent');
|
const logger = RMABLogger.create('QBittorrent');
|
||||||
|
|
||||||
export interface AddTorrentOptions {
|
export interface AddTorrentOptions {
|
||||||
savePath?: string;
|
|
||||||
category?: string;
|
category?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
skipChecking?: boolean;
|
skipChecking?: boolean;
|
||||||
sequentialDownload?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TorrentInfo {
|
export interface TorrentInfo {
|
||||||
@@ -276,7 +274,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
/**
|
/**
|
||||||
* Add magnet link - hash is extractable from URI (deterministic)
|
* Add magnet link - hash is extractable from URI (deterministic)
|
||||||
*/
|
*/
|
||||||
private async addMagnetLink(
|
protected async addMagnetLink(
|
||||||
magnetUrl: string,
|
magnetUrl: string,
|
||||||
category: string,
|
category: string,
|
||||||
options?: AddTorrentOptions
|
options?: AddTorrentOptions
|
||||||
@@ -299,20 +297,18 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
// Torrent doesn't exist, continue with adding
|
// 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
|
// 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.
|
// global seeding rules don't remove the torrent prematurely.
|
||||||
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
|
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
|
||||||
const form = new URLSearchParams({
|
const form = new URLSearchParams({
|
||||||
urls: magnetUrl,
|
urls: magnetUrl,
|
||||||
savepath: remoteSavePath,
|
|
||||||
category,
|
category,
|
||||||
paused: options?.paused ? 'true' : 'false',
|
paused: options?.paused ? 'true' : 'false',
|
||||||
sequentialDownload: (options?.sequentialDownload !== false).toString(),
|
|
||||||
ratioLimit: '-1',
|
ratioLimit: '-1',
|
||||||
seedingTimeLimit: '-1',
|
seedingTimeLimit: '-1',
|
||||||
});
|
});
|
||||||
@@ -341,7 +337,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
/**
|
/**
|
||||||
* Add .torrent file - download, parse, extract hash, upload content (deterministic)
|
* Add .torrent file - download, parse, extract hash, upload content (deterministic)
|
||||||
*/
|
*/
|
||||||
private async addTorrentFile(
|
protected async addTorrentFile(
|
||||||
torrentUrl: string,
|
torrentUrl: string,
|
||||||
category: string,
|
category: string,
|
||||||
options?: AddTorrentOptions
|
options?: AddTorrentOptions
|
||||||
@@ -446,11 +442,13 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
// Torrent doesn't exist, continue with adding
|
// 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
|
// 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 formData = new FormData();
|
||||||
|
|
||||||
const filename = parsedTorrent.name ? `${parsedTorrent.name}.torrent` : 'torrent.torrent';
|
const filename = parsedTorrent.name ? `${parsedTorrent.name}.torrent` : 'torrent.torrent';
|
||||||
@@ -458,11 +456,8 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
filename,
|
filename,
|
||||||
contentType: 'application/x-bittorrent',
|
contentType: 'application/x-bittorrent',
|
||||||
});
|
});
|
||||||
formData.append('savepath', remoteSavePath);
|
|
||||||
formData.append('category', category);
|
formData.append('category', category);
|
||||||
formData.append('paused', options?.paused ? 'true' : 'false');
|
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('ratioLimit', '-1');
|
||||||
formData.append('seedingTimeLimit', '-1');
|
formData.append('seedingTimeLimit', '-1');
|
||||||
|
|
||||||
@@ -494,7 +489,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Checks existing categories first, then creates or updates as needed
|
* Checks existing categories first, then creates or updates as needed
|
||||||
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
|
* 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) {
|
if (!this.cookie) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
@@ -1013,7 +1008,6 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
category: options?.category,
|
category: options?.category,
|
||||||
paused: options?.paused,
|
paused: options?.paused,
|
||||||
tags: ['audiobook'],
|
tags: ['audiobook'],
|
||||||
sequentialDownload: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1081,7 +1075,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
/**
|
/**
|
||||||
* Map a TorrentInfo object to the unified DownloadInfo format.
|
* Map a TorrentInfo object to the unified DownloadInfo format.
|
||||||
*/
|
*/
|
||||||
private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
|
protected mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
|
||||||
return {
|
return {
|
||||||
id: torrent.hash,
|
id: torrent.hash,
|
||||||
name: torrent.name,
|
name: torrent.name,
|
||||||
@@ -1194,7 +1188,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
/**
|
/**
|
||||||
* Extract info_hash from magnet link
|
* 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
|
// 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);
|
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
|
||||||
if (match) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/** Supported download client types — single source of truth */
|
/** 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 */
|
/** Identifies the specific download client software */
|
||||||
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
|
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
|
||||||
@@ -22,6 +22,7 @@ export const CLIENT_DISPLAY_NAMES: Record<DownloadClientType, string> = {
|
|||||||
sabnzbd: 'SABnzbd',
|
sabnzbd: 'SABnzbd',
|
||||||
nzbget: 'NZBGet',
|
nzbget: 'NZBGet',
|
||||||
transmission: 'Transmission',
|
transmission: 'Transmission',
|
||||||
|
rdtclient: 'RDT-Client',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get display name for a client type, falling back to the raw type */
|
/** 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',
|
sabnzbd: 'usenet',
|
||||||
nzbget: 'usenet',
|
nzbget: 'usenet',
|
||||||
transmission: 'torrent',
|
transmission: 'torrent',
|
||||||
|
rdtclient: 'torrent',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Unified download status across all clients */
|
/** Unified download status across all clients */
|
||||||
|
|||||||
@@ -864,8 +864,13 @@ async function cleanupDownloadAfterOrganize(
|
|||||||
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if this is a non-torrent indexer with cleanup enabled
|
// Check if this is a non-torrent indexer with cleanup enabled.
|
||||||
if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) {
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
|||||||
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||||
import { NZBGetService } from '@/lib/integrations/nzbget.service';
|
import { NZBGetService } from '@/lib/integrations/nzbget.service';
|
||||||
import { TransmissionService } from '@/lib/integrations/transmission.service';
|
import { TransmissionService } from '@/lib/integrations/transmission.service';
|
||||||
|
import { RDTClientService } from '@/lib/integrations/rdtclient.service';
|
||||||
import { PathMappingConfig } from '@/lib/utils/path-mapper';
|
import { PathMappingConfig } from '@/lib/utils/path-mapper';
|
||||||
import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
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);
|
return this.createNZBGetService(config, downloadDir);
|
||||||
case 'transmission':
|
case 'transmission':
|
||||||
return this.createTransmissionService(config, downloadDir);
|
return this.createTransmissionService(config, downloadDir);
|
||||||
|
case 'rdtclient':
|
||||||
|
return this.createRDTClientService(config, downloadDir);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported download client type: ${config.type}`);
|
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
|
* Migrate legacy single-client config to new multi-client format
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user