mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-21 21:50:10 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -6,22 +6,30 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClientCardProps {
|
||||
client: {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
customPath?: string;
|
||||
};
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientCardProps) {
|
||||
const typeName = client.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
|
||||
const typeColor = client.type === 'qbittorrent' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300';
|
||||
const typeName = getClientDisplayName(client.type);
|
||||
const typeColorMap: Record<string, string> = {
|
||||
qbittorrent: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
transmission: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
|
||||
sabnzbd: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
|
||||
nzbget: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300',
|
||||
};
|
||||
const typeColor = typeColorMap[client.type] || typeColorMap.qbittorrent;
|
||||
|
||||
// Truncate URL for display
|
||||
const displayUrl = client.url.length > 40 ? `${client.url.substring(0, 40)}...` : client.url;
|
||||
@@ -49,6 +57,11 @@ export function DownloadClientCard({ client, onEdit, onDelete }: DownloadClientC
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate" title={client.url}>
|
||||
{displayUrl}
|
||||
</p>
|
||||
{client.customPath && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 truncate" title={`Custom path: ${client.customPath}`}>
|
||||
Path: {client.customPath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ import { Button } from '@/components/ui/Button';
|
||||
import { DownloadClientCard } from './DownloadClientCard';
|
||||
import { DownloadClientModal } from './DownloadClientModal';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { DownloadClientType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClient {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
url: string;
|
||||
username?: string;
|
||||
@@ -24,24 +25,27 @@ interface DownloadClient {
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
}
|
||||
|
||||
interface DownloadClientManagementProps {
|
||||
mode: 'wizard' | 'settings';
|
||||
initialClients?: DownloadClient[];
|
||||
onClientsChange?: (clients: DownloadClient[]) => void;
|
||||
downloadDir?: string;
|
||||
}
|
||||
|
||||
export function DownloadClientManagement({
|
||||
mode,
|
||||
initialClients = [],
|
||||
onClientsChange,
|
||||
downloadDir: downloadDirProp,
|
||||
}: DownloadClientManagementProps) {
|
||||
const [clients, setClients] = useState<DownloadClient[]>(initialClients);
|
||||
const [modalState, setModalState] = useState<{
|
||||
isOpen: boolean;
|
||||
mode: 'add' | 'edit';
|
||||
clientType?: 'qbittorrent' | 'sabnzbd';
|
||||
clientType?: DownloadClientType;
|
||||
currentClient?: DownloadClient;
|
||||
}>({ isOpen: false, mode: 'add' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -51,14 +55,23 @@ export function DownloadClientManagement({
|
||||
clientId?: string;
|
||||
clientName?: string;
|
||||
}>({ isOpen: false });
|
||||
const [resolvedDownloadDir, setResolvedDownloadDir] = useState(downloadDirProp || '/downloads');
|
||||
|
||||
// Fetch clients when in settings mode
|
||||
// Fetch clients and download dir when in settings mode
|
||||
useEffect(() => {
|
||||
if (mode === 'settings') {
|
||||
fetchClients();
|
||||
fetchDownloadDir();
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
// Sync downloadDir prop (wizard mode)
|
||||
useEffect(() => {
|
||||
if (downloadDirProp) {
|
||||
setResolvedDownloadDir(downloadDirProp);
|
||||
}
|
||||
}, [downloadDirProp]);
|
||||
|
||||
// Sync with parent when clients change
|
||||
useEffect(() => {
|
||||
if (onClientsChange) {
|
||||
@@ -93,11 +106,26 @@ export function DownloadClientManagement({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddClient = (type: 'qbittorrent' | 'sabnzbd') => {
|
||||
// Check if this type already exists
|
||||
const existingClient = clients.find(c => c.type === type && c.enabled);
|
||||
const fetchDownloadDir = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.paths?.downloadDir) {
|
||||
setResolvedDownloadDir(data.paths.downloadDir);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: fall back to default
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddClient = (type: DownloadClientType) => {
|
||||
// Check if the protocol is already taken (regardless of enabled status)
|
||||
const protocol = CLIENT_PROTOCOL_MAP[type];
|
||||
const existingClient = clients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol);
|
||||
if (existingClient) {
|
||||
setError(`A ${type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd'} client is already configured.`);
|
||||
setError(`A ${protocol} client (${getClientDisplayName(existingClient.type)}) is already configured. Remove it first to add a different ${protocol} client.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,8 +238,8 @@ export function DownloadClientManagement({
|
||||
}
|
||||
};
|
||||
|
||||
const hasQBittorrent = clients.some(c => c.type === 'qbittorrent' && c.enabled);
|
||||
const hasSABnzbd = clients.some(c => c.type === 'sabnzbd' && c.enabled);
|
||||
const hasTorrentClient = clients.some(c => CLIENT_PROTOCOL_MAP[c.type] === 'torrent');
|
||||
const hasUsenetClient = clients.some(c => CLIENT_PROTOCOL_MAP[c.type] === 'usenet');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -233,9 +261,9 @@ export function DownloadClientManagement({
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Add Download Client
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* qBittorrent Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<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">
|
||||
@@ -249,9 +277,9 @@ export function DownloadClientManagement({
|
||||
Torrent
|
||||
</span>
|
||||
</div>
|
||||
{hasQBittorrent ? (
|
||||
{hasTorrentClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Already configured
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -265,8 +293,39 @@ export function DownloadClientManagement({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transmission 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">
|
||||
Transmission
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Torrent downloads
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-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('transmission')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
Add Transmission
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SABnzbd Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-6">
|
||||
<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>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
@@ -280,9 +339,9 @@ export function DownloadClientManagement({
|
||||
Usenet
|
||||
</span>
|
||||
</div>
|
||||
{hasSABnzbd ? (
|
||||
{hasUsenetClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Already configured
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
@@ -295,6 +354,37 @@ export function DownloadClientManagement({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NZBGet 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="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
NZBGet
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Usenet/NZB downloads
|
||||
</p>
|
||||
</div>
|
||||
<span className="inline-block text-xs px-2 py-1 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium">
|
||||
Usenet
|
||||
</span>
|
||||
</div>
|
||||
{hasUsenetClient ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Protocol already configured
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAddClient('nzbget')}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
Add NZBGet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -338,6 +428,7 @@ export function DownloadClientManagement({
|
||||
initialClient={modalState.currentClient}
|
||||
onSave={handleSaveClient}
|
||||
apiMode={mode}
|
||||
downloadDir={resolvedDownloadDir}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
|
||||
@@ -10,15 +10,16 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'add' | 'edit';
|
||||
clientType?: 'qbittorrent' | 'sabnzbd';
|
||||
clientType?: DownloadClientType;
|
||||
initialClient?: {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: DownloadClientType;
|
||||
name: string;
|
||||
url: string;
|
||||
username?: string;
|
||||
@@ -29,9 +30,11 @@ interface DownloadClientModalProps {
|
||||
remotePath?: string;
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
};
|
||||
onSave: (client: any) => Promise<void>;
|
||||
apiMode: 'wizard' | 'settings';
|
||||
downloadDir?: string;
|
||||
}
|
||||
|
||||
export function DownloadClientModal({
|
||||
@@ -42,9 +45,10 @@ export function DownloadClientModal({
|
||||
initialClient,
|
||||
onSave,
|
||||
apiMode,
|
||||
downloadDir = '/downloads',
|
||||
}: DownloadClientModalProps) {
|
||||
const type = mode === 'edit' ? initialClient?.type : clientType;
|
||||
const typeName = type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd';
|
||||
const typeName = type ? getClientDisplayName(type) : '';
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
@@ -57,6 +61,7 @@ export function DownloadClientModal({
|
||||
const [remotePath, setRemotePath] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [category, setCategory] = useState('readmeabook');
|
||||
const [customPath, setCustomPath] = useState('');
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -79,6 +84,7 @@ export function DownloadClientModal({
|
||||
setRemotePath(initialClient.remotePath || '');
|
||||
setLocalPath(initialClient.localPath || '');
|
||||
setCategory(initialClient.category || 'readmeabook');
|
||||
setCustomPath(initialClient.customPath || '');
|
||||
} else {
|
||||
// Add mode defaults
|
||||
setName(typeName);
|
||||
@@ -91,6 +97,7 @@ export function DownloadClientModal({
|
||||
setRemotePath('');
|
||||
setLocalPath('');
|
||||
setCategory('readmeabook');
|
||||
setCustomPath('');
|
||||
}
|
||||
setTestResult(null);
|
||||
setErrors({});
|
||||
@@ -113,6 +120,10 @@ export function DownloadClientModal({
|
||||
newErrors.password = 'API key is required';
|
||||
}
|
||||
|
||||
if (customPath.includes('..')) {
|
||||
newErrors.customPath = 'Path cannot contain ".."';
|
||||
}
|
||||
|
||||
if (remotePathMappingEnabled) {
|
||||
if (!remotePath.trim()) {
|
||||
newErrors.remotePath = 'Remote path is required when path mapping is enabled';
|
||||
@@ -140,8 +151,9 @@ export function DownloadClientModal({
|
||||
|
||||
const testData = {
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: type === 'qbittorrent' ? username : undefined,
|
||||
username: username || undefined,
|
||||
password: isPasswordMasked ? undefined : password,
|
||||
// Include clientId when editing so server can use stored password
|
||||
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
|
||||
@@ -202,11 +214,14 @@ export function DownloadClientModal({
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Strip leading/trailing slashes from customPath
|
||||
const sanitizedCustomPath = customPath.replace(/^\/+|\/+$/g, '').trim();
|
||||
|
||||
const clientData: any = {
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: type === 'qbittorrent' ? username : undefined,
|
||||
username: type !== 'sabnzbd' ? username : undefined,
|
||||
password: password === '********' ? undefined : password, // Don't send masked password on edit
|
||||
enabled,
|
||||
disableSSLVerify,
|
||||
@@ -214,6 +229,7 @@ export function DownloadClientModal({
|
||||
remotePath: remotePathMappingEnabled ? remotePath : undefined,
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
category,
|
||||
customPath: sanitizedCustomPath || undefined,
|
||||
};
|
||||
|
||||
if (mode === 'edit' && initialClient) {
|
||||
@@ -264,7 +280,7 @@ export function DownloadClientModal({
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={type === 'qbittorrent' ? 'http://localhost:8080' : 'http://localhost:8081'}
|
||||
placeholder={type === 'transmission' ? 'http://localhost:9091' : type === 'qbittorrent' ? 'http://localhost:8080' : type === 'nzbget' ? 'http://localhost:6789' : 'http://localhost:8081'}
|
||||
error={errors.url}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -272,8 +288,8 @@ export function DownloadClientModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Username (qBittorrent only) */}
|
||||
{type === 'qbittorrent' && (
|
||||
{/* Username (qBittorrent and Transmission) */}
|
||||
{type !== 'sabnzbd' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
@@ -290,13 +306,13 @@ export function DownloadClientModal({
|
||||
{/* Password / API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{type === 'qbittorrent' ? 'Password' : 'API Key'}
|
||||
{type === 'sabnzbd' ? 'API Key' : 'Password'}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={type === 'qbittorrent' ? 'Password' : 'API Key from SABnzbd Config > General'}
|
||||
placeholder={type === 'sabnzbd' ? 'API Key from SABnzbd Config > General' : 'Password'}
|
||||
error={errors.password}
|
||||
/>
|
||||
{type === 'sabnzbd' && (
|
||||
@@ -304,6 +320,11 @@ export function DownloadClientModal({
|
||||
Found in SABnzbd under Config → General → API Key
|
||||
</p>
|
||||
)}
|
||||
{type === 'nzbget' && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Configured in NZBGet under Settings → Security → ControlPassword
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSL Verification */}
|
||||
@@ -342,6 +363,27 @@ export function DownloadClientModal({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Custom Download Path */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Custom Download Path
|
||||
</label>
|
||||
<Input
|
||||
value={customPath}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
placeholder="e.g. torrents or usenet/books"
|
||||
error={errors.customPath}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Optional relative sub-path appended to the base download directory
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
Downloads to: {customPath.replace(/^\/+|\/+$/g, '').trim()
|
||||
? `${downloadDir}/${customPath.replace(/^\/+|\/+$/g, '').trim()}`
|
||||
: downloadDir}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start mb-3">
|
||||
|
||||
Reference in New Issue
Block a user