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
@@ -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>