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:
kikootwo
2026-02-09 19:45:43 -05:00
parent d7acd67aa4
commit 4b90b35748
117 changed files with 9346 additions and 1488 deletions
@@ -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">
@@ -5,12 +5,13 @@
'use client';
import React from 'react';
import React, { useState, useMemo } from 'react';
import {
TORRENT_CATEGORIES,
getChildIds,
areAllChildrenSelected,
isParentCategory,
getAllStandardCategoryIds,
} from '@/lib/utils/torrent-categories';
interface CategoryTreeViewProps {
@@ -24,7 +25,19 @@ export function CategoryTreeView({
onChange,
defaultCategories = [3030], // Default to audiobook category for backwards compatibility
}: CategoryTreeViewProps) {
const [customInput, setCustomInput] = useState('');
const [customError, setCustomError] = useState('');
const standardIds = useMemo(() => getAllStandardCategoryIds(), []);
// Derive custom categories from selected categories that aren't in the standard tree
const customCategories = useMemo(
() => selectedCategories.filter((id) => !standardIds.has(id)).sort((a, b) => a - b),
[selectedCategories, standardIds]
);
const isDefaultCategory = (categoryId: number) => defaultCategories.includes(categoryId);
const handleParentToggle = (parentId: number) => {
const childIds = getChildIds(parentId);
const allChildrenSelected = areAllChildrenSelected(parentId, selectedCategories);
@@ -57,6 +70,52 @@ export function CategoryTreeView({
}
};
const handleRemoveCustom = (categoryId: number) => {
onChange(selectedCategories.filter((id) => id !== categoryId));
};
const handleAddCustom = () => {
setCustomError('');
const trimmed = customInput.trim();
if (!trimmed) {
setCustomError('Enter a category ID');
return;
}
const parsed = parseInt(trimmed, 10);
if (isNaN(parsed) || !Number.isInteger(Number(trimmed)) || String(parsed) !== trimmed) {
setCustomError('Must be a whole number');
return;
}
if (parsed <= 0) {
setCustomError('Must be a positive number');
return;
}
if (standardIds.has(parsed)) {
setCustomError('This is a standard category — use the toggles above');
return;
}
if (selectedCategories.includes(parsed)) {
setCustomError('Already added');
return;
}
onChange([...selectedCategories, parsed]);
setCustomInput('');
};
const handleCustomKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCustom();
}
};
const isParentSelected = (parentId: number) => {
return areAllChildrenSelected(parentId, selectedCategories);
};
@@ -67,6 +126,7 @@ export function CategoryTreeView({
return (
<div className="space-y-5">
{/* Standard Categories */}
{TORRENT_CATEGORIES.map((category) => (
<div key={category.id} className="space-y-2">
{/* Parent Category Header */}
@@ -129,6 +189,85 @@ export function CategoryTreeView({
)}
</div>
))}
{/* Custom Categories Section */}
<div className="space-y-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 px-2 py-1">
<span className="text-base font-semibold text-gray-900 dark:text-gray-100 uppercase tracking-wide">
Custom
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Add custom Newznab/Torznab category IDs
</span>
</div>
{/* Existing custom categories */}
{customCategories.length > 0 && (
<div className="ml-4 space-y-2">
{customCategories.map((catId) => (
<div
key={catId}
className="flex items-center justify-between p-2.5 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-300">
Custom
</span>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
[{catId}]
</span>
</div>
<button
type="button"
onClick={() => handleRemoveCustom(catId)}
className="text-xs px-2.5 py-1 rounded-md text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 border border-red-200 dark:border-red-800 transition-colors"
>
Remove
</button>
</div>
))}
</div>
)}
{/* Add custom category input */}
<div className="ml-4">
<div className="flex items-center gap-2">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={customInput}
onChange={(e) => {
setCustomInput(e.target.value);
setCustomError('');
}}
onKeyDown={handleCustomKeyDown}
placeholder="Category ID"
className={`
w-32 px-3 py-1.5 text-sm rounded-lg border bg-white dark:bg-gray-800
text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900
${customError
? 'border-red-300 dark:border-red-700'
: 'border-gray-200 dark:border-gray-700'
}
`}
/>
<button
type="button"
onClick={handleAddCustom}
className="px-3 py-1.5 text-sm font-medium rounded-lg bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900"
>
Add
</button>
</div>
{customError && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1.5">
{customError}
</p>
)}
</div>
</div>
</div>
);
}
@@ -96,8 +96,6 @@ export function IndexerConfigModal({
const [errors, setErrors] = useState<{
priority?: string;
seedingTimeMinutes?: string;
audiobookCategories?: string;
ebookCategories?: string;
}>({});
// Reset form when modal opens or indexer changes
@@ -134,26 +132,12 @@ export function IndexerConfigModal({
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
}
if (audiobookCategories.length === 0) {
newErrors.audiobookCategories = 'At least one audiobook category must be selected';
}
if (ebookCategories.length === 0) {
newErrors.ebookCategories = 'At least one ebook category must be selected';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (!validate()) {
// If there's a category error, switch to the relevant tab
if (errors.audiobookCategories && activeTab !== 'audiobook') {
setActiveTab('audiobook');
} else if (errors.ebookCategories && activeTab !== 'ebook') {
setActiveTab('ebook');
}
return;
}
@@ -202,9 +186,12 @@ export function IndexerConfigModal({
// Get the current categories based on active tab
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
const currentError = activeTab === 'audiobook' ? errors.audiobookCategories : errors.ebookCategories;
const defaultForTab = activeTab === 'audiobook' ? DEFAULT_AUDIOBOOK_CATEGORIES : DEFAULT_EBOOK_CATEGORIES;
// Warning state: no categories means this indexer is effectively disabled for that type
const audiobookDisabled = audiobookCategories.length === 0;
const ebookDisabled = ebookCategories.length === 0;
return (
<Modal
isOpen={isOpen}
@@ -342,8 +329,8 @@ export function IndexerConfigModal({
}`}
>
AudioBook
{errors.audiobookCategories && (
<span className="ml-2 text-red-500">!</span>
{audiobookDisabled && (
<span className="ml-2 text-amber-500" title="No categories — disabled for audiobooks">!</span>
)}
</button>
<button
@@ -356,8 +343,8 @@ export function IndexerConfigModal({
}`}
>
EBook
{errors.ebookCategories && (
<span className="ml-2 text-red-500">!</span>
{ebookDisabled && (
<span className="ml-2 text-amber-500" title="No categories — disabled for ebooks">!</span>
)}
</button>
</div>
@@ -372,15 +359,23 @@ export function IndexerConfigModal({
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
{activeTab === 'audiobook'
? 'Categories to search for audiobooks. Default: Audio/Audiobook [3030]'
: 'Categories to search for e-books. Default: Books/EBook [7020]'}
{currentCategories.length > 0
? `Will search categories: [${currentCategories.join(', ')}]`
: activeTab === 'audiobook'
? 'Default: Audio/Audiobook [3030]'
: 'Default: Books/EBook [7020]'}
</p>
{currentError && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{currentError}
</p>
{/* Warning when all categories are deselected for the active tab */}
{currentCategories.length === 0 && (
<div className="flex items-start gap-2 mt-2 p-2.5 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<svg className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
<p className="text-sm text-amber-700 dark:text-amber-300">
No categories selected. This indexer will not be searched for {activeTab === 'audiobook' ? 'audiobooks' : 'ebooks'}.
</p>
</div>
)}
</div>
@@ -0,0 +1,90 @@
/**
* Component: Global User Settings Modal
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { Modal } from '@/components/ui/Modal';
interface GlobalUserSettingsModalProps {
isOpen: boolean;
onClose: () => void;
globalAutoApprove: boolean;
onToggleAutoApprove: (newValue: boolean) => void;
globalInteractiveSearch: boolean;
onToggleInteractiveSearch: (newValue: boolean) => void;
}
export function GlobalUserSettingsModal({
isOpen,
onClose,
globalAutoApprove,
onToggleAutoApprove,
globalInteractiveSearch,
onToggleInteractiveSearch,
}: GlobalUserSettingsModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Global User Settings" size="sm">
<div className="space-y-6">
{/* Auto-Approve Setting */}
<div className="flex items-start gap-4">
<button
onClick={() => onToggleAutoApprove(!globalAutoApprove)}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: globalAutoApprove ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={globalAutoApprove}
aria-label="Auto-Approve All Requests"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
globalAutoApprove ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<label
onClick={() => onToggleAutoApprove(!globalAutoApprove)}
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Auto-Approve All Requests
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings from the users table.
</p>
</div>
</div>
{/* Interactive Search Access Setting */}
<div className="flex items-start gap-4">
<button
onClick={() => onToggleInteractiveSearch(!globalInteractiveSearch)}
className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5"
style={{ backgroundColor: globalInteractiveSearch ? '#3b82f6' : '#d1d5db' }}
role="switch"
aria-checked={globalInteractiveSearch}
aria-label="Interactive Search Access"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
globalInteractiveSearch ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<label
onClick={() => onToggleInteractiveSearch(!globalInteractiveSearch)}
className="block text-sm font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Interactive Search Access
</label>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
When enabled, all users can manually search and select torrents/ebooks. When disabled, you can grant access per-user from the users table.
</p>
</div>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,184 @@
/**
* Component: User Permissions Modal
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { Modal } from '@/components/ui/Modal';
interface UserPermissionsUser {
id: string;
plexUsername: string;
plexEmail: string;
avatarUrl: string | null;
role: 'user' | 'admin';
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
}
interface UserPermissionsModalProps {
isOpen: boolean;
onClose: () => void;
user: UserPermissionsUser | null;
globalAutoApprove: boolean;
globalInteractiveSearch: boolean;
onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void;
onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void;
}
interface PermissionToggleProps {
label: string;
ariaLabel: string;
value: boolean;
disabled: boolean;
disabledMessage?: string;
description: string;
onToggle: () => void;
}
function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage, description, onToggle }: PermissionToggleProps) {
return (
<div className="flex items-start gap-4 p-3 border border-gray-200 dark:border-gray-700 rounded-lg">
<button
onClick={() => {
if (!disabled) onToggle();
}}
className={`relative inline-flex h-5 w-10 flex-shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 mt-0.5 ${
disabled ? 'opacity-60 cursor-not-allowed' : ''
}`}
style={{ backgroundColor: value ? '#3b82f6' : '#d1d5db' }}
disabled={disabled}
role="switch"
aria-checked={value}
aria-label={ariaLabel}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{label}
</div>
{disabledMessage ? (
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{disabledMessage}
</p>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{description}
</p>
)}
</div>
</div>
);
}
export function UserPermissionsModal({
isOpen,
onClose,
user,
globalAutoApprove,
globalInteractiveSearch,
onToggleAutoApprove,
onToggleInteractiveSearch,
}: UserPermissionsModalProps) {
if (!user) return null;
const isAdmin = user.role === 'admin';
// Auto-Approve resolution
const isAutoApproveGlobalOverride = !isAdmin && globalAutoApprove;
const isAutoApproveDisabled = isAdmin || isAutoApproveGlobalOverride;
const autoApproveValue = isAdmin ? true : isAutoApproveGlobalOverride ? true : (user.autoApproveRequests ?? false);
// Interactive Search resolution
const isSearchGlobalOverride = !isAdmin && globalInteractiveSearch;
const isSearchDisabled = isAdmin || isSearchGlobalOverride;
const searchValue = isAdmin ? true : isSearchGlobalOverride ? true : (user.interactiveSearchAccess ?? false);
const getDisabledMessage = (isAdminUser: boolean, isGlobalOverride: boolean, adminMessage: string, globalMessage: string): string | undefined => {
if (isAdminUser) return adminMessage;
if (isGlobalOverride) return globalMessage;
return undefined;
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="User Permissions" size="sm">
<div className="space-y-6">
{/* User Info */}
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
{user.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.plexUsername}
className="h-10 w-10 rounded-full"
/>
)}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user.plexUsername}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user.plexEmail || 'No email'}
</div>
</div>
<span
className={`ml-auto px-2 py-0.5 text-xs font-semibold rounded-full ${
isAdmin
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{user.role.toUpperCase()}
</span>
</div>
{/* Permissions Section */}
<div>
<h3 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Permissions
</h3>
<div className="space-y-3">
{/* Auto-Approve Permission */}
<PermissionToggle
label="Auto-Approve Requests"
ariaLabel="Auto-Approve Requests"
value={autoApproveValue}
disabled={isAutoApproveDisabled}
disabledMessage={getDisabledMessage(
isAdmin, isAutoApproveGlobalOverride,
'Admin requests are always auto-approved',
'Controlled by global auto-approve setting'
)}
description="When enabled, this user's requests are automatically processed without admin approval"
onToggle={() => onToggleAutoApprove(user, !autoApproveValue)}
/>
{/* Interactive Search Access Permission */}
<PermissionToggle
label="Interactive Search Access"
ariaLabel="Interactive Search Access"
value={searchValue}
disabled={isSearchDisabled}
disabledMessage={getDisabledMessage(
isAdmin, isSearchGlobalOverride,
'Admins always have interactive search access',
'Controlled by global interactive search setting'
)}
description="When enabled, this user can manually search and select torrents and ebooks"
onToggle={() => onToggleInteractiveSearch(user, !searchValue)}
/>
</div>
</div>
</div>
</Modal>
);
}
@@ -479,8 +479,8 @@ export function AudiobookDetailsModal({
)}
</div>
{/* Interactive Search - only if not available */}
{status.type !== 'available' && (
{/* Interactive Search - only if not available and user has permission */}
{status.type !== 'available' && (user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
<button
onClick={handleInteractiveSearch}
disabled={!user}
@@ -513,15 +513,17 @@ export function AudiobookDetailsModal({
</svg>
)}
</button>
<button
onClick={() => setShowInteractiveSearchEbook(true)}
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
title="Search Ebook Sources"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
{(user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
<button
onClick={() => setShowInteractiveSearchEbook(true)}
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
title="Search Ebook Sources"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
)}
</>
)}
</div>
+35 -2
View File
@@ -11,7 +11,10 @@ import { StatusBadge } from './StatusBadge';
import { Button } from '@/components/ui/Button';
import { useCancelRequest, useManualSearch } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
import { usePreferences } from '@/contexts/PreferencesContext';
import { useAuth } from '@/contexts/AuthContext';
import { InteractiveTorrentSearchModal } from './InteractiveTorrentSearchModal';
import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal';
interface RequestCardProps {
request: {
@@ -25,6 +28,7 @@ interface RequestCardProps {
completedAt?: string;
audiobook: {
id: string;
audibleAsin?: string;
title: string;
author: string;
coverArtUrl?: string;
@@ -36,8 +40,11 @@ interface RequestCardProps {
export function RequestCard({ request, showActions = true }: RequestCardProps) {
const { cancelRequest, isLoading } = useCancelRequest();
const { triggerManualSearch, isLoading: isManualSearching } = useManualSearch();
const { squareCovers } = usePreferences();
const { user } = useAuth();
const [showError, setShowError] = React.useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = React.useState(false);
const [showDetailsModal, setShowDetailsModal] = React.useState(false);
const requestType = request.type || 'audiobook';
const isEbook = requestType === 'ebook';
@@ -46,7 +53,9 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
const isActive = ['searching', 'downloading', 'processing'].includes(request.status);
const isFailed = request.status === 'failed';
// Ebook requests don't support interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
// Interactive search also requires the interactiveSearch permission
const hasInteractiveSearchAccess = user?.role === 'admin' || user?.permissions?.interactiveSearch !== false;
const canSearch = hasInteractiveSearchAccess && !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
const handleCancel = async () => {
if (window.confirm('Are you sure you want to cancel this request?')) {
@@ -94,7 +103,19 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
<div className="flex gap-3 sm:gap-4 p-3 sm:p-4">
{/* Cover Art */}
<div className="flex-shrink-0">
<div className="relative w-16 h-24 sm:w-24 sm:h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700">
<div
className={cn(
'relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700',
squareCovers
? 'w-16 sm:w-24 aspect-square'
: 'w-16 sm:w-24 aspect-[2/3]',
request.audiobook.audibleAsin && 'cursor-pointer hover:opacity-90 transition-opacity'
)}
onClick={() => request.audiobook.audibleAsin && setShowDetailsModal(true)}
role={request.audiobook.audibleAsin ? 'button' : undefined}
tabIndex={request.audiobook.audibleAsin ? 0 : undefined}
onKeyDown={(e) => e.key === 'Enter' && request.audiobook.audibleAsin && setShowDetailsModal(true)}
>
{request.audiobook.coverArtUrl ? (
<Image
src={request.audiobook.coverArtUrl}
@@ -277,6 +298,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) {
author: request.audiobook.author,
}}
/>
{/* Audiobook Details Modal */}
{request.audiobook.audibleAsin && (
<AudiobookDetailsModal
asin={request.audiobook.audibleAsin}
isOpen={showDetailsModal}
onClose={() => setShowDetailsModal(false)}
requestStatus={request.status}
isAvailable={['available', 'downloaded'].includes(request.status)}
hideRequestActions
/>
)}
</div>
);
}
+71 -5
View File
@@ -5,11 +5,29 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
const GITHUB_REPO = 'kikootwo/ReadMeABook';
const REMOTE_PACKAGE_URL = `https://raw.githubusercontent.com/${GITHUB_REPO}/refs/heads/main/package.json`;
const UPDATE_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
function compareVersions(current: string, latest: string): number {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
const a = parse(current);
const b = parse(latest);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (b[i] || 0) - (a[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
export function VersionBadge() {
const [version, setVersion] = useState<string | null>(null);
const [rawVersion, setRawVersion] = useState<string | null>(null);
const [commit, setCommit] = useState<string | null>(null);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [updateAvailable, setUpdateAvailable] = useState(false);
useEffect(() => {
// Try to get version from build-time env var first (instant, no API call)
@@ -17,6 +35,7 @@ export function VersionBadge() {
if (buildTimeVersion && buildTimeVersion !== 'unknown') {
setVersion(`v${buildTimeVersion}`);
setRawVersion(buildTimeVersion);
// Also get commit for tooltip if available
const buildTimeCommit = process.env.NEXT_PUBLIC_GIT_COMMIT;
if (buildTimeCommit && buildTimeCommit !== 'unknown') {
@@ -31,6 +50,7 @@ export function VersionBadge() {
.then((res) => res.json())
.then((data) => {
setVersion(data.version);
setRawVersion(data.fullVersion);
if (data.commit && data.commit !== 'unknown') {
setCommit(data.commit.substring(0, 7));
}
@@ -42,20 +62,66 @@ export function VersionBadge() {
}
}, []);
const checkForUpdates = useCallback(() => {
if (!rawVersion || rawVersion === 'unknown') return;
fetch(REMOTE_PACKAGE_URL)
.then((res) => res.json())
.then((data) => {
if (data.version) {
setLatestVersion(data.version);
setUpdateAvailable(compareVersions(rawVersion, data.version) > 0);
}
})
.catch(() => {
// Silently fail - update check is non-critical
});
}, [rawVersion]);
// Check for updates on mount and periodically (every 6 hours)
useEffect(() => {
if (!rawVersion || rawVersion === 'unknown') return;
checkForUpdates();
const interval = setInterval(checkForUpdates, UPDATE_CHECK_INTERVAL);
return () => clearInterval(interval);
}, [rawVersion, checkForUpdates]);
if (!version) {
return null;
}
const tooltipText = commit ? `${version} (${commit})` : version;
const releaseUrl = rawVersion && rawVersion !== 'unknown'
? `https://github.com/${GITHUB_REPO}/releases/tag/v${rawVersion}`
: `https://github.com/${GITHUB_REPO}/releases`;
const tooltipText = updateAvailable && latestVersion
? `${version}${commit ? ` (${commit})` : ''} — Update available: v${latestVersion}`
: commit ? `${version} (${commit})` : version;
return (
<div
className="inline-flex items-center px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm"
<a
href={updateAvailable && latestVersion
? `https://github.com/${GITHUB_REPO}/releases/tag/v${latestVersion}`
: releaseUrl
}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-gradient-to-r from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 border border-gray-300 dark:border-gray-600 shadow-sm hover:shadow-md transition-shadow no-underline"
title={tooltipText}
>
<span className="text-xs font-mono font-medium text-gray-700 dark:text-gray-300">
{version}
</span>
</div>
{updateAvailable && latestVersion && (
<span className="inline-flex items-center gap-1 text-xs font-mono font-medium text-amber-600 dark:text-amber-400">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
</span>
v{latestVersion}
</span>
)}
</a>
);
}