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
+1
View File
@@ -97,6 +97,7 @@ export interface PathsSettings {
downloadDir: string;
mediaDir: string;
audiobookPathTemplate?: string;
ebookPathTemplate?: string;
metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean;
}
+133 -53
View File
@@ -18,6 +18,12 @@ interface PathsTabProps {
onValidationChange: (isValid: boolean) => void;
}
interface TemplatePreview {
isValid: boolean;
error?: string;
previewPaths?: string[];
}
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
paths,
@@ -25,31 +31,52 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
onValidationChange,
});
// Live preview state (client-side validation)
const [livePreview, setLivePreview] = useState<{
isValid: boolean;
error?: string;
previewPaths?: string[];
} | null>(null);
// Live preview state for audiobook template
const [audiobookPreview, setAudiobookPreview] = useState<TemplatePreview | null>(null);
// Update live preview whenever template changes
// Live preview state for ebook template
const [ebookPreview, setEbookPreview] = useState<TemplatePreview | null>(null);
// Update audiobook live preview whenever template changes
useEffect(() => {
const template = paths.audiobookPathTemplate || '{author}/{title} {asin}';
const validation = validateTemplate(template);
if (validation.valid) {
setLivePreview({
setAudiobookPreview({
isValid: true,
previewPaths: generateMockPreviews(template),
});
} else {
setLivePreview({
setAudiobookPreview({
isValid: false,
error: validation.error,
});
}
}, [paths.audiobookPathTemplate]);
// Update ebook live preview whenever template changes
useEffect(() => {
const template = paths.ebookPathTemplate || '{author}/{title} {asin}';
const validation = validateTemplate(template);
if (validation.valid) {
setEbookPreview({
isValid: true,
previewPaths: generateMockPreviews(template),
});
} else {
setEbookPreview({
isValid: false,
error: validation.error,
});
}
}, [paths.ebookPathTemplate]);
const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
return (
<div className="space-y-6 max-w-2xl">
<div>
@@ -74,7 +101,7 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
className="font-mono"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Temporary location for torrent downloads (kept for seeding)
Temporary location for downloads before they are organized into the media library
</p>
</div>
@@ -111,61 +138,24 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
Customize how audiobooks are organized within the media directory
</p>
{/* Variable Reference Panel */}
<div className="mt-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
Available Variables
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
</div>
</div>
</div>
{/* Live Preview - Client-side validation */}
{livePreview && !livePreview.isValid && (
{/* Audiobook Validation Error */}
{audiobookPreview && !audiobookPreview.isValid && (
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
<span className="flex-shrink-0 mt-0.5"></span>
<div className="flex-1">
<span>{livePreview.error || 'Invalid template format'}</span>
<span>{audiobookPreview.error || 'Invalid template format'}</span>
</div>
</div>
)}
{/* Live Preview Examples - Show while editing */}
{livePreview && livePreview.isValid && livePreview.previewPaths && (
{/* Audiobook Preview Examples */}
{audiobookPreview && audiobookPreview.isValid && audiobookPreview.previewPaths && (
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Preview Examples
</h4>
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{livePreview.previewPaths.map((preview, index) => (
{audiobookPreview.previewPaths.map((preview, index) => (
<div key={index} className="text-xs">
{paths.mediaDir || '/media/audiobooks'}/{preview}
</div>
@@ -175,6 +165,96 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
)}
</div>
{/* Ebook Organization Template */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Ebook Organization Template
</label>
<div className="flex gap-2">
<Input
type="text"
value={paths.ebookPathTemplate || '{author}/{title} {asin}'}
onChange={(e) => updatePath('ebookPathTemplate', e.target.value)}
placeholder="{author}/{title} {asin}"
className="font-mono flex-1"
/>
<Button
variant="outline"
onClick={() => updatePath('ebookPathTemplate', paths.audiobookPathTemplate || '{author}/{title} {asin}')}
disabled={ebookMatchesAudiobook}
className="whitespace-nowrap text-sm"
>
Match Audiobook
</Button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Customize how ebooks are organized within the media directory
</p>
{/* Ebook Validation Error */}
{ebookPreview && !ebookPreview.isValid && (
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
<span className="flex-shrink-0 mt-0.5"></span>
<div className="flex-1">
<span>{ebookPreview.error || 'Invalid template format'}</span>
</div>
</div>
)}
{/* Ebook Preview Examples */}
{ebookPreview && ebookPreview.isValid && ebookPreview.previewPaths && (
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
Preview Examples
</h4>
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
{ebookPreview.previewPaths.map((preview, index) => (
<div key={index} className="text-xs">
{paths.mediaDir || '/media/audiobooks'}/{preview}
</div>
))}
</div>
</div>
)}
</div>
{/* Variable Reference Panel (shared for both templates) */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
Available Variables
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
</div>
<div>
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
</div>
</div>
</div>
{/* Metadata Tagging Toggle */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-4">
@@ -41,6 +41,7 @@ export function usePathsSettings({ paths, onChange, onValidationChange }: UsePat
downloadDir: paths.downloadDir,
mediaDir: paths.mediaDir,
audiobookPathTemplate: paths.audiobookPathTemplate,
ebookPathTemplate: paths.ebookPathTemplate,
}),
});
+131 -49
View File
@@ -11,6 +11,8 @@ import Link from 'next/link';
import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { GlobalUserSettingsModal } from '@/components/admin/users/GlobalUserSettingsModal';
import { UserPermissionsModal } from '@/components/admin/users/UserPermissionsModal';
interface User {
id: string;
@@ -25,6 +27,7 @@ interface User {
updatedAt: string;
lastLoginAt: string | null;
autoApproveRequests: boolean | null;
interactiveSearchAccess: boolean | null;
_count: {
requests: number;
};
@@ -48,6 +51,10 @@ function AdminUsersPageContent() {
'/api/admin/settings/auto-approve',
authenticatedFetcher
);
const { data: globalInteractiveSearchData, mutate: mutateGlobalInteractiveSearch } = useSWR(
'/api/admin/settings/interactive-search',
authenticatedFetcher
);
const [editDialog, setEditDialog] = useState<{
isOpen: boolean;
user: User | null;
@@ -66,6 +73,9 @@ function AdminUsersPageContent() {
}>({ isOpen: false, user: null });
const [deleting, setDeleting] = useState(false);
const [globalAutoApprove, setGlobalAutoApprove] = useState<boolean>(false);
const [globalInteractiveSearch, setGlobalInteractiveSearch] = useState<boolean>(true);
const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false);
const [permissionsUserId, setPermissionsUserId] = useState<string | null>(null);
const toast = useToast();
const isLoading = !data && !error;
@@ -81,6 +91,15 @@ function AdminUsersPageContent() {
}
}, [globalAutoApproveData]);
// Sync global interactive search state (default to true if not set)
useEffect(() => {
if (globalInteractiveSearchData?.interactiveSearchAccess !== undefined) {
setGlobalInteractiveSearch(globalInteractiveSearchData.interactiveSearchAccess);
} else if (globalInteractiveSearchData !== undefined && globalInteractiveSearchData.interactiveSearchAccess === undefined) {
setGlobalInteractiveSearch(true);
}
}, [globalInteractiveSearchData]);
const handleGlobalAutoApproveToggle = async (newValue: boolean) => {
// Optimistic update
setGlobalAutoApprove(newValue);
@@ -102,6 +121,27 @@ function AdminUsersPageContent() {
}
};
const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => {
// Optimistic update
setGlobalInteractiveSearch(newValue);
try {
await fetchJSON('/api/admin/settings/interactive-search', {
method: 'PATCH',
body: JSON.stringify({ interactiveSearchAccess: newValue }),
});
toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`);
mutateGlobalInteractiveSearch();
mutate(); // Refresh users list to show updated state
} catch (err) {
// Revert on error
setGlobalInteractiveSearch(!newValue);
const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting';
toast.error(errorMsg);
console.error(err);
}
};
const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => {
console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue });
@@ -136,6 +176,33 @@ function AdminUsersPageContent() {
}
};
const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => {
// Optimistic update
const previousUsers = data?.users || [];
const optimisticUsers = previousUsers.map((u: User) =>
u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u
);
mutate({ users: optimisticUsers }, false);
try {
await fetchJSON(`/api/admin/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify({
role: user.role,
interactiveSearchAccess: newValue
}),
});
toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`);
mutate(); // Refresh users list
} catch (err) {
// Revert on error
mutate({ users: previousUsers }, false);
const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting';
toast.error(errorMsg);
console.error(err);
}
};
const showEditDialog = (user: User) => {
setEditRole(user.role);
setEditDialog({ isOpen: true, user });
@@ -273,6 +340,7 @@ function AdminUsersPageContent() {
}
const users: User[] = data?.users || [];
const permissionsUser = permissionsUserId ? users.find((u) => u.id === permissionsUserId) ?? null : null;
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
@@ -287,40 +355,26 @@ function AdminUsersPageContent() {
Manage user roles and permissions
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
{/* Global Auto-Approve Toggle */}
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
<div className="flex items-start gap-4">
<div className="flex items-center gap-3">
<button
onClick={() => handleGlobalAutoApproveToggle(!globalAutoApprove)}
className="relative inline-flex h-6 w-11 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' }}
onClick={() => setGlobalSettingsOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${globalAutoApprove ? 'translate-x-6' : 'translate-x-1'}`}
/>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Global User Permissions</span>
</button>
<div className="flex-1">
<label
onClick={() => handleGlobalAutoApproveToggle(!globalAutoApprove)}
className="block text-base font-semibold text-gray-900 dark:text-gray-100 cursor-pointer"
>
Auto-Approve All Requests
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
When enabled, all user requests are automatically processed. When disabled, you can set per-user approval settings below.
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
</div>
@@ -403,7 +457,7 @@ function AdminUsersPageContent() {
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Auto-Approve
Permissions
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Requests
@@ -471,31 +525,34 @@ function AdminUsersPageContent() {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<button
onClick={() => setPermissionsUserId(user.id)}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
{user.role === 'admin' ? (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<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>
Always On
Full Access
</span>
) : globalAutoApprove ? (
<span className="text-xs text-gray-500 dark:text-gray-400">
Global Setting
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
Global Default
</span>
) : (user.autoApproveRequests ?? false) ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Auto-Approve
</span>
) : (
<button
onClick={() => handleUserAutoApproveToggle(user, !(user.autoApproveRequests ?? false))}
className="relative inline-flex h-5 w-10 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"
style={{ backgroundColor: (user.autoApproveRequests ?? false) ? '#3b82f6' : '#d1d5db' }}
title={`Toggle auto-approve for ${user.plexUsername}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${(user.autoApproveRequests ?? false) ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Manual
</span>
)}
</div>
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{user._count.requests}
@@ -587,7 +644,7 @@ function AdminUsersPageContent() {
<li> <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
<li> <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
<li> <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
<li> <strong>Auto-Approve:</strong> When the global setting is enabled, all requests are automatically processed. When disabled, you can control auto-approval per user. Admin requests are always auto-approved.</li>
<li> <strong>Permissions:</strong> Click a user&apos;s permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.</li>
<li> <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
<li> <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
<li> <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
@@ -722,6 +779,31 @@ function AdminUsersPageContent() {
isLoading={deleting}
variant="danger"
/>
{/* Global User Settings Modal */}
<GlobalUserSettingsModal
isOpen={globalSettingsOpen}
onClose={() => setGlobalSettingsOpen(false)}
globalAutoApprove={globalAutoApprove}
onToggleAutoApprove={handleGlobalAutoApproveToggle}
globalInteractiveSearch={globalInteractiveSearch}
onToggleInteractiveSearch={handleGlobalInteractiveSearchToggle}
/>
{/* User Permissions Modal */}
<UserPermissionsModal
isOpen={permissionsUser !== null}
onClose={() => setPermissionsUserId(null)}
user={permissionsUser}
globalAutoApprove={globalAutoApprove}
globalInteractiveSearch={globalInteractiveSearch}
onToggleAutoApprove={(user, newValue) => {
handleUserAutoApproveToggle(user as User, newValue);
}}
onToggleInteractiveSearch={(user, newValue) => {
handleUserInteractiveSearchToggle(user as User, newValue);
}}
/>
</div>
</div>
);
+18 -22
View File
@@ -6,10 +6,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { getSABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('API.Admin.Downloads');
@@ -55,6 +55,7 @@ export async function GET(request: NextRequest) {
torrentName: true,
torrentHash: true,
nzbId: true,
downloadClientId: true,
downloadClient: true, // qbittorrent, sabnzbd, or direct
torrentSizeBytes: true,
startedAt: true,
@@ -68,9 +69,9 @@ export async function GET(request: NextRequest) {
take: 20,
});
// Get configured download client type
// Get download client manager
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
const manager = getDownloadClientManager(configService);
// Format response with speed and ETA from download client
const formatted = await Promise.all(
@@ -98,24 +99,19 @@ export async function GET(request: NextRequest) {
eta = speed > 0 ? Math.round(remainingBytes / speed) : null;
}
}
} else if (downloadClient === 'qbittorrent' || (!downloadClient && clientType === 'qbittorrent')) {
// Get torrent hash from download history
const torrentHash = downloadHistory?.torrentHash;
if (torrentHash) {
const qbService = await getQBittorrentService();
const torrentInfo = await qbService.getTorrent(torrentHash);
speed = torrentInfo.dlspeed;
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
}
} else if (downloadClient === 'sabnzbd' || (!downloadClient && clientType === 'sabnzbd')) {
// Get NZB ID from download history
const nzbId = downloadHistory?.nzbId;
if (nzbId) {
const sabnzbdService = await getSABnzbdService();
const nzbInfo = await sabnzbdService.getNZB(nzbId);
if (nzbInfo) {
speed = nzbInfo.downloadSpeed;
eta = nzbInfo.timeLeft > 0 ? nzbInfo.timeLeft : null;
} else {
// Use unified interface for all download clients (qBittorrent, SABnzbd, etc.)
const clientId = downloadHistory?.downloadClientId || downloadHistory?.torrentHash || downloadHistory?.nzbId;
if (clientId && downloadClient) {
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (client) {
const info = await client.getDownload(clientId);
if (info) {
speed = info.downloadSpeed;
eta = info.eta > 0 ? info.eta : null;
}
}
}
}
@@ -11,6 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { PathMapper } from '@/lib/utils/path-mapper';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
@@ -35,9 +36,9 @@ export async function PUT(request: NextRequest) {
logger.warn('DEPRECATED: Using legacy single-client API. Please use /api/admin/settings/download-clients instead.');
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -97,7 +98,7 @@ export async function PUT(request: NextRequest) {
const updatedClient: DownloadClientConfig = {
id: existingIndex >= 0 ? existingClients[existingIndex].id : randomUUID(),
type,
name: type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
name: getClientDisplayName(type),
enabled: true,
url,
username: username || undefined,
@@ -137,6 +138,12 @@ export async function PUT(request: NextRequest) {
} else if (type === 'sabnzbd') {
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
invalidateSABnzbdService();
} else if (type === 'nzbget') {
const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service');
invalidateNZBGetService();
} else if (type === 'transmission') {
const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service');
invalidateTransmissionService();
}
return NextResponse.json({
@@ -37,6 +37,7 @@ export async function PUT(
remotePath,
localPath,
category,
customPath,
} = body;
const config = await getConfigService();
@@ -53,6 +54,14 @@ export async function PUT(
const existingClient = clients[clientIndex];
// Validate customPath: reject paths containing ".."
if (customPath && customPath.includes('..')) {
return NextResponse.json(
{ error: 'Custom path cannot contain ".."' },
{ status: 400 }
);
}
// Build updated client (preserve fields not in request)
const updatedClient: DownloadClientConfig = {
...existingClient,
@@ -66,6 +75,7 @@ export async function PUT(
remotePath: remotePath !== undefined ? remotePath : existingClient.remotePath,
localPath: localPath !== undefined ? localPath : existingClient.localPath,
category: category !== undefined ? category : existingClient.category,
customPath: customPath !== undefined ? (customPath || undefined) : existingClient.customPath,
};
// Validate path mapping if enabled
@@ -6,8 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES, CLIENT_PROTOCOL_MAP, DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
@@ -62,12 +62,13 @@ export async function POST(request: NextRequest) {
remotePath,
localPath,
category,
customPath,
} = body;
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -99,21 +100,30 @@ export async function POST(request: NextRequest) {
}
}
// Check for duplicate type (only one client per type for now)
// Check for duplicate protocol (only one client per protocol)
const config = await getConfigService();
const manager = getDownloadClientManager(config);
const existingClients = await manager.getAllClients();
const duplicateType = existingClients.find(c => c.type === type && c.enabled);
if (duplicateType) {
const protocol = CLIENT_PROTOCOL_MAP[type as DownloadClientType];
const duplicateProtocol = existingClients.find(c => CLIENT_PROTOCOL_MAP[c.type] === protocol);
if (duplicateProtocol) {
return NextResponse.json(
{ error: `A ${type} client is already configured. Please disable or remove it first.` },
{ error: `A ${protocol} client (${getClientDisplayName(duplicateProtocol.type)}) is already configured. Remove it first to add a different ${protocol} client.` },
{ status: 400 }
);
}
// Create new client config for testing (with plaintext password)
// qBittorrent credentials are optional (supports IP whitelist auth)
// Validate customPath: reject paths containing ".."
if (customPath && customPath.includes('..')) {
return NextResponse.json(
{ error: 'Custom path cannot contain ".."' },
{ status: 400 }
);
}
const newClient: DownloadClientConfig = {
id: randomUUID(),
type,
@@ -127,6 +137,7 @@ export async function POST(request: NextRequest) {
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: category || 'readmeabook',
customPath: customPath || undefined,
};
// Test connection before saving
@@ -6,8 +6,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClients.Test');
@@ -23,6 +23,7 @@ export async function POST(request: NextRequest) {
const {
clientId, // Optional: existing client ID to use stored password
type,
name: clientName,
url,
username,
password,
@@ -33,9 +34,9 @@ export async function POST(request: NextRequest) {
} = body;
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -87,7 +88,7 @@ export async function POST(request: NextRequest) {
const testConfig: DownloadClientConfig = {
id: 'test',
type,
name: 'Test Client',
name: clientName || type,
enabled: true,
url,
username: effectiveUsername || '',
@@ -0,0 +1,91 @@
/**
* Component: Admin Interactive Search Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Admin.Settings.InteractiveSearch');
const CONFIG_KEY = 'interactive_search_access';
/**
* GET /api/admin/settings/interactive-search
* Get current global interactive search access setting
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const config = await prisma.configuration.findUnique({
where: { key: CONFIG_KEY },
});
// Default to true if not configured (backward compatibility)
const interactiveSearchAccess = config === null ? true : config.value === 'true';
return NextResponse.json({ interactiveSearchAccess });
} catch (error) {
logger.error('Failed to fetch interactive search setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to fetch interactive search setting' },
{ status: 500 }
);
}
});
});
}
/**
* PATCH /api/admin/settings/interactive-search
* Update global interactive search access setting
*/
export async function PATCH(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { interactiveSearchAccess } = body;
// Validate input
if (typeof interactiveSearchAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid input. interactiveSearchAccess must be a boolean' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: CONFIG_KEY },
create: {
key: CONFIG_KEY,
value: interactiveSearchAccess.toString(),
},
update: {
value: interactiveSearchAccess.toString(),
},
});
logger.info(`Interactive search access setting updated to: ${interactiveSearchAccess}`, {
userId: req.user?.sub,
});
return NextResponse.json({ interactiveSearchAccess });
} catch (error) {
logger.error('Failed to update interactive search setting', {
error: error instanceof Error ? error.message : String(error)
});
return NextResponse.json(
{ error: 'Failed to update interactive search setting' },
{ status: 500 }
);
}
});
});
}
+25 -2
View File
@@ -15,7 +15,7 @@ export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate, ebookPathTemplate, metadataTaggingEnabled, chapterMergingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -59,6 +59,20 @@ export async function PUT(request: NextRequest) {
});
}
// Update ebook path template
if (ebookPathTemplate !== undefined) {
await prisma.configuration.upsert({
where: { key: 'ebook_path_template' },
update: { value: ebookPathTemplate },
create: {
key: 'ebook_path_template',
value: ebookPathTemplate,
category: 'automation',
description: 'Template for organizing ebook files in media directory',
},
});
}
// Update metadata tagging setting
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
@@ -90,12 +104,21 @@ export async function PUT(request: NextRequest) {
configService.clearCache('download_dir');
configService.clearCache('media_dir');
configService.clearCache('audiobook_path_template');
configService.clearCache('ebook_path_template');
configService.clearCache('metadata_tagging_enabled');
configService.clearCache('chapter_merging_enabled');
// Invalidate qBittorrent service singleton to force reload of download_dir
// Invalidate all download client singletons to force reload of download_dir
const { invalidateDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
invalidateDownloadClientManager();
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
invalidateQBittorrentService();
const { invalidateSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
invalidateSABnzbdService();
const { invalidateNZBGetService } = await import('@/lib/integrations/nzbget.service');
invalidateNZBGetService();
const { invalidateTransmissionService } = await import('@/lib/integrations/transmission.service');
invalidateTransmissionService();
return NextResponse.json({
success: true,
+1
View File
@@ -125,6 +125,7 @@ export async function GET(request: NextRequest) {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
audiobookPathTemplate: configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
ebookPathTemplate: configMap.get('ebook_path_template') || configMap.get('audiobook_path_template') || '{author}/{title} {asin}',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
chapterMergingEnabled: configMap.get('chapter_merging_enabled') === 'true',
},
@@ -1,14 +1,16 @@
/**
* Component: Admin Settings Test Download Client API
* Component: Admin Settings Test Download Client API (DEPRECATED)
* Documentation: documentation/settings-pages.md
*
* DEPRECATED: Use /api/admin/settings/download-clients/test instead.
* Maintained for backward compatibility.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager } from '@/lib/services/download-client-manager.service';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.TestDownloadClient');
@@ -19,6 +21,7 @@ export async function POST(request: NextRequest) {
try {
const {
type,
name: clientName,
url,
username,
password,
@@ -37,9 +40,9 @@ export async function POST(request: NextRequest) {
);
}
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
@@ -64,53 +67,28 @@ export async function POST(request: NextRequest) {
actualPassword = matchingClient.password;
}
// Validate required fields per client type and test connection
let version: string | undefined;
// Build a temporary config for testing
const testConfig: DownloadClientConfig = {
id: 'legacy-test',
type,
name: clientName || type,
enabled: true,
url,
username: username || '',
password: actualPassword || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: 'readmeabook',
};
if (type === 'qbittorrent') {
logger.debug('Testing qBittorrent connection');
if (!username || !actualPassword) {
return NextResponse.json(
{ success: false, error: 'Username and password are required for qBittorrent' },
{ status: 400 }
);
}
// Test qBittorrent connection
version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
actualPassword,
disableSSLVerify || false
);
} else if (type === 'sabnzbd') {
logger.debug('Testing SABnzbd connection');
if (!actualPassword) {
return NextResponse.json(
{ success: false, error: 'API key (password) is required for SABnzbd' },
{ status: 400 }
);
}
// Test SABnzbd connection
const sabnzbd = new SABnzbdService(url, actualPassword, 'readmeabook', disableSSLVerify || false);
const result = await sabnzbd.testConnection();
if (!result.success) {
return NextResponse.json(
{
success: false,
error: result.error || 'Failed to connect to SABnzbd',
},
{ status: 500 }
);
}
version = result.version;
}
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const result = await manager.testConnection(testConfig);
// If path mapping enabled, validate local path exists
if (remotePathMappingEnabled) {
if (result.success && remotePathMappingEnabled) {
if (!remotePath || !localPath) {
return NextResponse.json(
{
@@ -136,10 +114,14 @@ export async function POST(request: NextRequest) {
}
}
return NextResponse.json({
success: true,
version,
});
if (result.success) {
return NextResponse.json({ success: true, message: result.message });
}
return NextResponse.json(
{ success: false, error: result.message },
{ status: 400 }
);
} catch (error) {
logger.error('Download client test failed', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json(
+22 -4
View File
@@ -19,7 +19,7 @@ export async function PUT(
try {
const { id } = await params;
const body = await request.json();
const { role, autoApproveRequests } = body;
const { role, autoApproveRequests, interactiveSearchAccess } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
@@ -37,6 +37,14 @@ export async function PUT(
);
}
// Validate interactiveSearchAccess (optional)
if (interactiveSearchAccess !== undefined && interactiveSearchAccess !== null && typeof interactiveSearchAccess !== 'boolean') {
return NextResponse.json(
{ error: 'Invalid interactiveSearchAccess. Must be a boolean or null' },
{ status: 400 }
);
}
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
@@ -91,21 +99,30 @@ export async function PUT(
);
}
// Validate that admins cannot have autoApproveRequests set to false
// Validate that admins cannot have permissions set to false
if (role === 'admin' && autoApproveRequests === false) {
return NextResponse.json(
{ error: 'Admins must always auto-approve requests. Cannot set autoApproveRequests to false for admin users.' },
{ status: 400 }
);
}
if (role === 'admin' && interactiveSearchAccess === false) {
return NextResponse.json(
{ error: 'Admins always have interactive search access. Cannot set interactiveSearchAccess to false for admin users.' },
{ status: 400 }
);
}
// Prepare update data
const updateData: { role: string; autoApproveRequests?: boolean | null } = { role };
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
if (autoApproveRequests !== undefined) {
updateData.autoApproveRequests = autoApproveRequests;
}
if (interactiveSearchAccess !== undefined) {
updateData.interactiveSearchAccess = interactiveSearchAccess;
}
// Update user role and autoApproveRequests
// Update user
const updatedUser = await prisma.user.update({
where: { id },
data: updateData,
@@ -114,6 +131,7 @@ export async function PUT(
plexUsername: true,
role: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
},
});
+1
View File
@@ -31,6 +31,7 @@ export async function GET(request: NextRequest) {
updatedAt: true,
lastLoginAt: true,
autoApproveRequests: true,
interactiveSearchAccess: true,
_count: {
select: {
requests: true,
@@ -17,6 +17,7 @@ import { groupIndexersByCategories } from '@/lib/utils/indexer-grouping';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
import {
searchByAsin,
searchByTitle,
@@ -83,6 +84,21 @@ export async function POST(
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const { asin } = await params;
// Check interactive search access permission
if (req.user) {
const callingUser = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, interactiveSearchAccess: true },
});
if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' },
{ status: 403 }
);
}
}
const body = await request.json().catch(() => ({}));
const customTitle = body.customTitle as string | undefined;
@@ -410,9 +426,14 @@ async function searchIndexersForInteractive(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by ebook categories
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`);
// Get Prowlarr service
const prowlarr = await getProwlarrService();
@@ -70,9 +70,14 @@ export async function POST(request: NextRequest) {
// Group indexers by their category configuration
// This minimizes API calls while ensuring each indexer only searches its configured categories
const groups = groupIndexersByCategories(indexersConfig);
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchQuery: title });
// Log each group for transparency
groups.forEach((group, index) => {
+13
View File
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { resolvePermission, getGlobalBooleanSetting } from '@/lib/utils/permissions';
/**
* GET /api/auth/me
@@ -37,6 +38,7 @@ export async function GET(request: NextRequest) {
authProvider: true,
createdAt: true,
lastLoginAt: true,
interactiveSearchAccess: true,
},
});
@@ -53,6 +55,14 @@ export async function GET(request: NextRequest) {
// Determine if user is local admin (setup admin with local authentication)
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
// Resolve effective permissions
const globalInteractiveSearch = await getGlobalBooleanSetting('interactive_search_access', true);
const effectiveInteractiveSearch = resolvePermission(
user.role,
user.interactiveSearchAccess,
globalInteractiveSearch
);
return NextResponse.json({
user: {
id: user.id,
@@ -65,6 +75,9 @@ export async function GET(request: NextRequest) {
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
permissions: {
interactiveSearch: effectiveInteractiveSearch,
},
},
});
});
@@ -321,9 +321,14 @@ async function searchIndexersForInteractive(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by ebook categories
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} indexers in ${groups.length} group(s)`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} indexers in ${groups.length} group(s)`);
// Get Prowlarr service
const prowlarr = await getProwlarrService();
@@ -9,6 +9,7 @@ import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
import { RMABLogger } from '@/lib/utils/logger';
import { resolveInteractiveSearchAccess } from '@/lib/utils/permissions';
const logger = RMABLogger.create('API.InteractiveSearch');
@@ -71,6 +72,18 @@ export async function POST(
);
}
// Check interactive search access permission
const callingUser = await prisma.user.findUnique({
where: { id: req.user.id },
select: { role: true, interactiveSearchAccess: true },
});
if (!callingUser || !(await resolveInteractiveSearchAccess(callingUser.role, callingUser.interactiveSearchAccess))) {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have interactive search access. Contact your admin to enable this permission.' },
{ status: 403 }
);
}
// Get enabled indexers from configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
+35 -22
View File
@@ -7,6 +7,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { RMABLogger } from '@/lib/utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('API.RequestById');
@@ -200,28 +201,11 @@ export async function PATCH(
// Get download path from the appropriate download client
let downloadPath: string;
if (downloadHistory.torrentHash) {
// qBittorrent - get path from torrent info
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
downloadPath = `${torrent.save_path}/${torrent.name}`;
} else if (downloadHistory.nzbId) {
// SABnzbd - get path from NZB info
const { getSABnzbdService } = await import('@/lib/integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
if (!nzbInfo || !nzbInfo.downloadPath) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Download path not available from SABnzbd',
},
{ status: 400 }
);
}
downloadPath = nzbInfo.downloadPath;
} else {
// Get download path via unified interface
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
if (!clientId || clientType === 'direct') {
return NextResponse.json(
{
error: 'ValidationError',
@@ -231,6 +215,35 @@ export async function PATCH(
);
}
const { getConfigService } = await import('@/lib/services/config.service');
const { getDownloadClientManager } = await import('@/lib/services/download-client-manager.service');
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (!client) {
return NextResponse.json(
{
error: 'ValidationError',
message: `No ${clientType} client configured`,
},
{ status: 400 }
);
}
const info = await client.getDownload(clientId);
if (!info?.downloadPath) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Download path not available from ${client.clientType}`,
},
{ status: 400 }
);
}
downloadPath = info.downloadPath;
await jobQueue.addOrganizeJob(
id,
requestWithData.audiobook.id,
+6 -2
View File
@@ -9,11 +9,14 @@ import bcrypt from 'bcrypt';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getClientDisplayName } from '@/lib/interfaces/download-client.interface';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.Complete');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const {
backendMode,
@@ -28,7 +31,7 @@ export async function POST(request: NextRequest) {
downloadClient,
paths,
bookdate,
} = await request.json();
} = await req.json();
// Validate backend mode
if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) {
@@ -401,7 +404,7 @@ export async function POST(request: NextRequest) {
downloadClientsArray = [{
id: `temp-${Date.now()}`,
type: downloadClient.type,
name: downloadClient.type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
name: getClientDisplayName(downloadClient.type),
enabled: true,
url: downloadClient.url,
username: downloadClient.username,
@@ -562,4 +565,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -4,10 +4,12 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { serverUrl, apiToken } = await request.json();
const { serverUrl, apiToken } = await req.json();
if (!serverUrl) {
return NextResponse.json(
@@ -79,4 +81,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+28 -44
View File
@@ -4,15 +4,18 @@
*/
import { NextRequest, NextResponse } from 'next/server';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { SUPPORTED_CLIENT_TYPES } from '@/lib/interfaces/download-client.interface';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestDownloadClient');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { type, url, username, password, disableSSLVerify } = await request.json();
const { type, name, url, username, password, disableSSLVerify } = await req.json();
if (!type || !url) {
return NextResponse.json(
@@ -21,59 +24,39 @@ export async function POST(request: NextRequest) {
);
}
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
if (!SUPPORTED_CLIENT_TYPES.includes(type)) {
return NextResponse.json(
{ success: false, error: 'Invalid client type. Must be qbittorrent or sabnzbd' },
{ success: false, error: `Invalid client type. Must be one of: ${SUPPORTED_CLIENT_TYPES.join(', ')}` },
{ status: 400 }
);
}
// Validate required fields per client type
// qBittorrent credentials are optional (supports IP whitelist auth)
if (type === 'qbittorrent') {
// Test qBittorrent connection (empty credentials work with IP whitelist)
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username || '',
password || '',
disableSSLVerify || false
);
// Build a temporary config for testing
const testConfig: DownloadClientConfig = {
id: 'setup-test',
type,
name: name || type,
enabled: true,
url,
username: username || '',
password: password || '',
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: false,
};
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const result = await manager.testConnection(testConfig);
if (result.success) {
return NextResponse.json({
success: true,
version,
});
} else if (type === 'sabnzbd') {
if (!password) {
return NextResponse.json(
{ success: false, error: 'API key (password) is required for SABnzbd' },
{ status: 400 }
);
}
// Test SABnzbd connection
const sabnzbd = new SABnzbdService(url, password, 'readmeabook', disableSSLVerify || false);
const result = await sabnzbd.testConnection();
if (!result.success) {
return NextResponse.json(
{
success: false,
error: result.error || 'Failed to connect to SABnzbd',
},
{ status: 500 }
);
}
return NextResponse.json({
success: true,
version: result.version,
message: result.message,
});
}
// Should never reach here
return NextResponse.json(
{ success: false, error: 'Invalid client type' },
{ success: false, error: result.message },
{ status: 400 }
);
} catch (error) {
@@ -86,4 +69,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestOIDC');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const body = await request.json();
const body = await req.json();
const { issuerUrl, clientId, clientSecret } = body;
// Validate required fields
@@ -93,4 +95,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -6,6 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
import { validateTemplate, generateMockPreviews } from '@/lib/utils/path-template.util';
@@ -45,8 +46,9 @@ async function testPath(dirPath: string): Promise<boolean> {
}
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { downloadDir, mediaDir, audiobookPathTemplate } = await request.json();
const { downloadDir, mediaDir, audiobookPathTemplate } = await req.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
@@ -126,4 +128,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestPlex');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { url, token } = await request.json();
const { url, token } = await req.json();
if (!url || !token) {
return NextResponse.json(
@@ -61,4 +63,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+4 -1
View File
@@ -5,13 +5,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
import { requireSetupIncomplete } from '@/lib/middleware/auth';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('API.Setup.TestProwlarr');
export async function POST(request: NextRequest) {
return requireSetupIncomplete(request, async (req) => {
try {
const { url, apiKey } = await request.json();
const { url, apiKey } = await req.json();
if (!url || !apiKey) {
return NextResponse.json(
@@ -50,4 +52,5 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
});
}
+6 -1
View File
@@ -10,12 +10,14 @@ import { Header } from '@/components/layout/Header';
import { RequestCard } from '@/components/requests/RequestCard';
import { useRequests } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { cn } from '@/lib/utils/cn';
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
export default function RequestsPage() {
const { user } = useAuth();
const { squareCovers } = usePreferences();
const [filter, setFilter] = useState<FilterStatus>('all');
// Always fetch only the current user's requests (even for admins)
@@ -133,7 +135,10 @@ export default function RequestsPage() {
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
>
<div className="flex gap-4">
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
<div className={cn(
'w-24 bg-gray-300 dark:bg-gray-700 rounded',
squareCovers ? 'aspect-square' : 'aspect-[2/3]'
)}></div>
<div className="flex-1 space-y-3">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
+1
View File
@@ -495,6 +495,7 @@ export default function SetupWizard() {
return (
<DownloadClientStep
downloadClients={state.downloadClients}
downloadDir={state.downloadDir}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
+7 -2
View File
@@ -8,10 +8,11 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/Button';
import { DownloadClientManagement } from '@/components/admin/download-clients/DownloadClientManagement';
import { DownloadClientType } from '@/lib/interfaces/download-client.interface';
interface DownloadClient {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: DownloadClientType;
name: string;
enabled: boolean;
url: string;
@@ -22,10 +23,12 @@ interface DownloadClient {
remotePath?: string;
localPath?: string;
category?: string;
customPath?: string;
}
interface DownloadClientStepProps {
downloadClients: DownloadClient[];
downloadDir?: string;
onUpdate: (field: string, value: any) => void;
onNext: () => void;
onBack: () => void;
@@ -33,6 +36,7 @@ interface DownloadClientStepProps {
export function DownloadClientStep({
downloadClients,
downloadDir,
onUpdate,
onNext,
onBack,
@@ -66,7 +70,7 @@ export function DownloadClientStep({
Configure Download Clients
</h2>
<p className="text-gray-600 dark:text-gray-400">
Add at least one download client. You can configure both qBittorrent (torrents) and SABnzbd (Usenet) to search across all indexer types.
Add at least one download client. You can configure a torrent client (qBittorrent or Transmission) and/or a usenet client (SABnzbd or NZBGet) to search across all indexer types.
</p>
</div>
@@ -80,6 +84,7 @@ export function DownloadClientStep({
mode="wizard"
initialClients={clients}
onClientsChange={handleClientsChange}
downloadDir={downloadDir}
/>
<div className="flex justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
+8 -8
View File
@@ -17,13 +17,13 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
<div className="text-center space-y-4">
<div className="flex justify-center">
<div
className="w-20 h-20 rounded-full flex items-center justify-center p-4"
className="w-20 h-20 rounded-full flex items-center justify-center p-2 overflow-hidden"
style={{ backgroundColor: '#f7f4f3' }}
>
<img
src="/rmab_32x32.png"
src="/RMAB_1024x1024_ICON.png"
alt="ReadMeABook Logo"
className="w-full h-full object-contain"
className="w-full h-full object-contain relative top-[3px]"
/>
</div>
</div>
@@ -57,9 +57,9 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
/>
</svg>
<div>
<strong className="text-gray-900 dark:text-gray-100">Plex Media Server</strong>
<strong className="text-gray-900 dark:text-gray-100">Plex or Audiobookshelf</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Your Plex server URL and authentication token
Your media server URL and authentication credentials
</p>
</div>
</li>
@@ -79,7 +79,7 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
<div>
<strong className="text-gray-900 dark:text-gray-100">Prowlarr</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Indexer aggregator for searching torrents (URL and API key)
Indexer aggregator for searching torrents and usenet (URL and API key)
</p>
</div>
</li>
@@ -98,10 +98,10 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
</svg>
<div>
<strong className="text-gray-900 dark:text-gray-100">
qBittorrent or SABnzbd
Download Client
</strong>
<p className="text-sm text-gray-600 dark:text-gray-400">
Download client for torrents (qBittorrent) or Usenet/NZB (SABnzbd)
qBittorrent, Transmission, SABnzbd, or NZBGet
</p>
</div>
</li>
@@ -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>
);
}
+35 -1
View File
@@ -8,6 +8,10 @@
import React, { createContext, useContext, useState, useEffect, ReactNode, useRef } from 'react';
import { isTokenExpired, getRefreshTimeMs } from '@/lib/utils/jwt-client';
interface UserPermissions {
interactiveSearch: boolean;
}
interface User {
id: string;
plexId: string;
@@ -16,6 +20,7 @@ interface User {
role: string;
avatarUrl?: string;
authProvider?: string | null; // 'plex' | 'oidc' | 'local' | null
permissions?: UserPermissions;
}
interface AuthContextType {
@@ -73,7 +78,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const storedRefreshToken = localStorage.getItem('refreshToken');
if (storedRefreshToken && !isTokenExpired(storedRefreshToken)) {
// Refresh token is still valid, attempt refresh
refreshTokenInternal(storedRefreshToken).finally(() => {
refreshTokenInternal(storedRefreshToken).then(() => {
// Fetch fresh user data from server to pick up role changes,
// avatar updates, etc. - mirrors the non-expired path below.
const currentToken = localStorage.getItem('accessToken');
if (currentToken) {
fetch('/api/auth/me', {
headers: { 'Authorization': `Bearer ${currentToken}` },
})
.then((res) => res.json())
.then((data) => {
if (data.user) {
setUser(data.user);
localStorage.setItem('user', JSON.stringify(data.user));
}
})
.catch((error) => {
console.error('Failed to fetch fresh user data:', error);
});
}
}).finally(() => {
setIsLoading(false);
});
return;
@@ -135,6 +159,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAccessToken(data.accessToken);
localStorage.setItem('accessToken', data.accessToken);
// Restore user state from localStorage if not already in React state.
// This is critical for the mount-time refresh path: when the access
// token has expired but the refresh token is still valid, the mount
// effect calls refreshTokenInternal without ever calling setUser,
// leaving user as null and the app appearing logged-out.
const storedUserData = localStorage.getItem('user');
if (storedUserData) {
setUser(JSON.parse(storedUserData));
}
// Schedule next refresh
scheduleTokenRefresh(data.accessToken);
} else {
+69
View File
@@ -0,0 +1,69 @@
/**
* Component: Audio Format Constants
* Documentation: documentation/phase3/file-organization.md
*
* Centralized audio format definitions used across the application.
* Add new formats here to enable support in all subsystems.
*/
/**
* All supported audio file extensions for audiobook detection and file organization.
* Used by: file-organizer.ts, files-hash.ts
*/
export const AUDIO_EXTENSIONS = [
'.m4b',
'.m4a',
'.mp3',
'.mp4',
'.aa',
'.aax',
'.flac',
'.ogg',
] as const;
/**
* Audio formats supported by the chapter merger (FFmpeg concat + M4B output).
* Formats here can be detected, probed, ordered, and merged into a single M4B.
* Note: .aa/.aax excluded (DRM-protected, cannot be decoded by FFmpeg without keys).
* Note: .ogg excluded (FFmpeg concat demuxer does not support Ogg container).
*/
export const CHAPTER_MERGE_FORMATS = [
'.mp3',
'.m4a',
'.m4b',
'.mp4',
'.aac',
'.flac',
] as const;
/**
* Audio formats supported by metadata tagging via FFmpeg.
* Each format maps to a specific FFmpeg output format flag and tagging strategy.
*/
export const METADATA_TAG_FORMATS = [
'.m4b',
'.m4a',
'.mp3',
'.mp4',
'.flac',
] as const;
/**
* Formats that use MP4/M4A container tags (iTunes-style metadata).
* These use `-f mp4` output format in FFmpeg.
*/
export const MP4_CONTAINER_FORMATS = ['.m4b', '.m4a', '.mp4'] as const;
/**
* Audio format identifiers detectable in torrent/NZB titles.
* Used by Prowlarr service for metadata extraction and ranking algorithm for scoring.
*/
export const TORRENT_TITLE_FORMATS = ['M4B', 'M4A', 'MP3', 'FLAC'] as const;
export type TorrentTitleFormat = (typeof TORRENT_TITLE_FORMATS)[number];
/**
* Type helper for the format field on TorrentResult.
* 'OTHER' is used when no recognized format is detected in the title.
*/
export type AudioFormat = TorrentTitleFormat | 'OTHER';
+925
View File
@@ -0,0 +1,925 @@
/**
* Component: NZBGet Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import zlib from 'zlib';
import { RMABLogger } from '@/lib/utils/logger';
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
const logger = RMABLogger.create('NZBGet');
// =========================================================================
// NZBGet-specific types
// =========================================================================
/** NZBGet queue group item from listgroups() */
interface NZBGetGroupItem {
NZBID: number;
NZBName: string;
Status: string;
FileSizeMB: number;
DownloadedSizeMB: number;
RemainingSizeMB: number;
DownloadTimeSec: number;
Category: string;
DestDir: string;
FinalDir: string;
MaxPriority: number;
ActiveDownloads: number;
Health: number;
PostInfoText: string;
PostStageProgress: number;
}
/** NZBGet history item from history() */
interface NZBGetHistoryItem {
NZBID: number;
Name: string;
Status: string;
Category: string;
FileSizeMB: number;
DownloadedSizeMB: number;
DestDir: string;
FinalDir: string;
DownloadTimeSec: number;
PostTotalTimeSec: number;
ParStatus: string;
UnpackStatus: string;
DeleteStatus: string;
MarkStatus: string;
HistoryTime: number;
FailedArticles: number;
TotalArticles: number;
}
/** NZBGet config entry from config() */
interface NZBGetConfigItem {
Name: string;
Value: string;
}
/** NZBGet status response from status() */
interface NZBGetStatus {
DownloadRate: number;
RemainingSizeMB: number;
DownloadedSizeMB: number;
DownloadPaused: boolean;
ServerStandBy: boolean;
}
/** Internal NZB info (normalized before mapping to DownloadInfo) */
interface NZBInfo {
nzbId: string;
name: string;
size: number;
bytesDownloaded: number;
progress: number;
status: DownloadStatus;
downloadSpeed: number;
eta: number;
category: string;
downloadPath?: string;
completedAt?: Date;
errorMessage?: string;
}
// =========================================================================
// NZBGet Service
// =========================================================================
export class NZBGetService implements IDownloadClient {
readonly clientType: DownloadClientType = 'nzbget';
readonly protocol: ProtocolType = 'usenet';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
private password: string;
private defaultCategory: string;
private defaultDownloadDir: string;
private disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
private pathMappingConfig: PathMappingConfig;
constructor(
baseUrl: string,
username: string,
password: string,
defaultCategory: string = 'readmeabook',
defaultDownloadDir: string = '/downloads',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username || '';
this.password = password || '';
this.defaultCategory = defaultCategory;
this.defaultDownloadDir = defaultDownloadDir;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
if (this.disableSSLVerify && this.baseUrl.startsWith('https')) {
this.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
}
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
httpsAgent: this.httpsAgent,
auth: {
username: this.username,
password: this.password,
},
});
}
// =========================================================================
// JSON-RPC Communication
// =========================================================================
/**
* Make a JSON-RPC call to NZBGet.
* All NZBGet API calls go through POST /jsonrpc with Basic Auth.
*/
private async rpc<T = any>(method: string, params: any[] = []): Promise<T> {
const response = await this.client.post('/jsonrpc', {
method,
params,
});
if (response.data?.error) {
const errorMsg = typeof response.data.error === 'string'
? response.data.error
: response.data.error.message || JSON.stringify(response.data.error);
throw new Error(`NZBGet RPC error (${method}): ${errorMsg}`);
}
return response.data?.result;
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Test connection to NZBGet
*/
async testConnection(): Promise<ConnectionTestResult> {
try {
const version = await this.rpc<string>('version');
if (!version) {
return {
success: false,
message: 'Connected but failed to get NZBGet version',
};
}
return {
success: true,
version,
message: `Connected to NZBGet v${version}`,
};
} catch (error) {
return {
success: false,
message: this.formatConnectionError(error),
};
}
}
/**
* Add a download via the unified interface.
* Downloads the NZB file from the source URL and uploads to NZBGet via append().
*/
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
const category = options?.category || this.defaultCategory;
// Ensure category exists with correct path before every download
// (Matches SABnzbd/qBittorrent behavior — lightweight config read + conditional write)
await this.ensureCategory();
// Download the NZB file content from the source URL (Prowlarr proxy)
let nzbBuffer: Buffer;
let filename: string;
try {
logger.info('Downloading NZB file from source URL...');
const nzbResponse = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
});
nzbBuffer = Buffer.from(nzbResponse.data);
if (nzbBuffer.length === 0) {
throw new Error('NZB file is empty (0 bytes)');
}
logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`);
// Detect and decompress gzip-compressed NZB files
// Prowlarr/indexers may serve .nzb.gz files which need decompression before upload
if (nzbBuffer[0] === 0x1f && nzbBuffer[1] === 0x8b) {
logger.info('NZB file is gzip-compressed, decompressing...');
nzbBuffer = zlib.gunzipSync(nzbBuffer);
logger.info(`Decompressed NZB file: ${nzbBuffer.length} bytes`);
}
filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']);
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status) {
throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`);
}
if (error.code === 'ECONNREFUSED') {
throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?');
}
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.');
}
}
throw error;
}
// Upload to NZBGet via append()
// Parameters: Filename, Content (base64), Category, Priority, AddToTop, AddPaused,
// DupeKey, DupeScore, DupeMode, AutoCategory, PPParameters
const base64Content = nzbBuffer.toString('base64');
const priority = this.mapPriority(options?.priority);
const nzbId = await this.rpc<number>('append', [
filename, // Filename
base64Content, // Content (base64-encoded NZB)
category, // Category
priority, // Priority (0=normal, 50=high, 100=very high, 900=force)
false, // AddToTop
options?.paused || false, // AddPaused
'', // DupeKey
0, // DupeScore
'FORCE', // DupeMode — RMAB manages its own lifecycle, skip NZBGet dupe detection
[], // PPParameters
]);
if (!nzbId || nzbId <= 0) {
// Log diagnostic info to help debug rejected NZBs
const contentPreview = nzbBuffer.slice(0, 100).toString('utf-8');
logger.error('NZBGet rejected the NZB file', {
filename,
contentLength: nzbBuffer.length,
base64Length: base64Content.length,
contentPreview: contentPreview.substring(0, 80),
returnedId: nzbId,
});
throw new Error('NZBGet rejected the NZB file');
}
const id = String(nzbId);
logger.info(`Added NZB: ${id} (${filename})`);
return id;
}
/**
* Get current status of a download.
* Checks queue (listgroups) first, then history.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const nzbId = parseInt(id, 10);
if (isNaN(nzbId)) {
logger.error(`Invalid NZB ID: ${id}`);
return null;
}
// Check queue first
const groups = await this.rpc<NZBGetGroupItem[]>('listgroups', [0]);
const groupItem = groups?.find(g => g.NZBID === nzbId);
if (groupItem) {
return this.mapGroupToDownloadInfo(groupItem);
}
// Not in queue, check history
const history = await this.rpc<NZBGetHistoryItem[]>('history', [false]);
const historyItem = history?.find(h => h.NZBID === nzbId);
if (historyItem) {
return this.mapHistoryToDownloadInfo(historyItem);
}
return null;
}
/**
* Pause a download via editqueue GroupPause
*/
async pauseDownload(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
const result = await this.rpc<boolean>('editqueue', ['GroupPause', '', [nzbId]]);
if (!result) {
throw new Error(`Failed to pause download ${id}`);
}
logger.info(`Paused download: ${id}`);
}
/**
* Resume a download via editqueue GroupResume
*/
async resumeDownload(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
const result = await this.rpc<boolean>('editqueue', ['GroupResume', '', [nzbId]]);
if (!result) {
throw new Error(`Failed to resume download ${id}`);
}
logger.info(`Resumed download: ${id}`);
}
/**
* Delete a download from NZBGet.
* Tries queue first (GroupFinalDelete), then history (HistoryFinalDelete).
*/
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
const nzbId = parseInt(id, 10);
logger.info(`Deleting download: ${id} (deleteFiles: ${deleteFiles})`);
// Try deleting from queue first
const groups = await this.rpc<NZBGetGroupItem[]>('listgroups', [0]);
const inQueue = groups?.some(g => g.NZBID === nzbId);
if (inQueue) {
const command = deleteFiles ? 'GroupFinalDelete' : 'GroupDelete';
const result = await this.rpc<boolean>('editqueue', [command, '', [nzbId]]);
if (!result) {
throw new Error(`Failed to delete download ${id} from queue`);
}
logger.info(`Deleted download ${id} from queue`);
return;
}
// Try deleting from history
const command = deleteFiles ? 'HistoryFinalDelete' : 'HistoryDelete';
const result = await this.rpc<boolean>('editqueue', [command, '', [nzbId]]);
if (!result) {
throw new Error(`Failed to delete download ${id} from history`);
}
logger.info(`Deleted download ${id} from history`);
}
/**
* Post-download cleanup: archive from NZBGet history.
* Uses HistoryDelete to hide the item from visible history (preserves in hidden archive).
* Analogous to SABnzbd's archive behavior.
*/
async postProcess(id: string): Promise<void> {
const nzbId = parseInt(id, 10);
logger.info(`Archiving completed download from history: ${id}`);
try {
const result = await this.rpc<boolean>('editqueue', ['HistoryDelete', '', [nzbId]]);
if (!result) {
throw new Error(`NZBGet returned false for HistoryDelete`);
}
logger.info(`Successfully archived ${id} from history`);
} catch (error) {
logger.error(`Failed to archive ${id} from history`, {
error: error instanceof Error ? error.message : String(error),
});
throw new Error(`NZB ${id} not found in history or failed to archive`);
}
}
// =========================================================================
// Category Management
// =========================================================================
/**
* Ensure the category exists in NZBGet with the correct download path.
*
* NZBGet categories are config entries (Category1.Name, Category1.DestDir, etc.).
* Reads existing config, checks for our category, creates/updates via saveconfig().
*
* CRITICAL: NZBGet's saveconfig() does a FULL config replacement passing only
* our entries would wipe every other setting and destroy the instance. We must
* always read the full config, merge our changes, and write the entire config back.
*
* After creating a new category, we call reload() so NZBGet picks up the new
* category DestDir immediately. reload() is safe when the config is correct.
*
* Called before every download (matches SABnzbd/qBittorrent pattern).
* Lightweight: reads config, writes only if category is missing or path changed.
*/
async ensureCategory(): Promise<void> {
try {
logger.debug('ensureCategory() called - syncing category with NZBGet');
const config = await this.rpc<NZBGetConfigItem[]>('config');
if (!config) {
logger.warn('Failed to get NZBGet config, skipping category check');
return;
}
// Find the main DestDir (NZBGet's base download directory)
const destDirEntry = config.find(c => c.Name === 'DestDir');
const nzbgetDestDir = destDirEntry?.Value || '';
logger.debug('NZBGet config retrieved', {
destDir: nzbgetDestDir || '(not configured)',
});
// Apply reverse path mapping to get the path from NZBGet's perspective
const desiredPath = PathMapper.reverseTransform(this.defaultDownloadDir, this.pathMappingConfig);
logger.debug('Category path calculation', {
rmabDownloadDir: this.defaultDownloadDir,
desiredPathForNZBGet: desiredPath,
nzbgetDestDir,
pathMappingEnabled: this.pathMappingConfig.enabled,
});
// Find existing categories and our category slot
const { existingSlot, nextSlot } = this.findCategorySlot(config, this.defaultCategory);
if (existingSlot !== null) {
// Category exists - check if DestDir needs updating
const currentDestDir = config.find(c => c.Name === `Category${existingSlot}.DestDir`)?.Value || '';
if (this.normalizePath(currentDestDir) !== this.normalizePath(desiredPath)) {
logger.info(`Updating category "${this.defaultCategory}" DestDir from "${currentDestDir}" to "${desiredPath}"`);
const updatedConfig = this.mergeConfigEntries(config, [
{ Name: `Category${existingSlot}.DestDir`, Value: desiredPath },
]);
await this.rpc('saveconfig', [updatedConfig]);
await this.reloadAndWait();
} else {
logger.debug(`Category "${this.defaultCategory}" already configured correctly`);
}
} else {
// Create new category — merge into full config so we don't wipe existing settings
logger.info(`Creating category "${this.defaultCategory}" in slot ${nextSlot} with DestDir: "${desiredPath}"`);
const updatedConfig = this.mergeConfigEntries(config, [
{ Name: `Category${nextSlot}.Name`, Value: this.defaultCategory },
{ Name: `Category${nextSlot}.DestDir`, Value: desiredPath },
{ Name: `Category${nextSlot}.Unpack`, Value: 'yes' },
]);
await this.rpc('saveconfig', [updatedConfig]);
await this.reloadAndWait();
}
} catch (error) {
logger.error('Failed to ensure category', {
error: error instanceof Error ? error.message : String(error),
});
// Don't throw - category issues shouldn't block downloads
}
}
/**
* Read-only entries returned by NZBGet's config() RPC that must NOT be
* written back via saveconfig(). These are runtime/system properties.
*/
private static readonly READ_ONLY_CONFIG_KEYS = new Set([
'ConfigFile',
'AppBin',
'AppDir',
'Version',
]);
/**
* Merge new/updated config entries into the full NZBGet config.
* Returns a complete config array safe to pass to saveconfig().
*
* Filters out read-only system entries (ConfigFile, AppBin, AppDir, Version)
* that config() returns but saveconfig() rejects.
*
* For entries that already exist (by Name), replaces the value.
* For new entries, appends them to the array.
*/
private mergeConfigEntries(
fullConfig: NZBGetConfigItem[],
changes: NZBGetConfigItem[]
): NZBGetConfigItem[] {
const merged: NZBGetConfigItem[] = [];
for (const entry of fullConfig) {
// Skip read-only system entries that saveconfig() rejects
if (NZBGetService.READ_ONLY_CONFIG_KEYS.has(entry.Name)) {
continue;
}
const override = changes.find(c => c.Name === entry.Name);
merged.push(override ? { Name: entry.Name, Value: override.Value } : { Name: entry.Name, Value: entry.Value });
}
// Append any entries that don't exist in the current config
for (const change of changes) {
if (!fullConfig.some(entry => entry.Name === change.Name)) {
merged.push({ Name: change.Name, Value: change.Value });
}
}
return merged;
}
/**
* Find the category slot number for an existing category or determine the next available slot.
*/
private findCategorySlot(
config: NZBGetConfigItem[],
categoryName: string
): { existingSlot: number | null; nextSlot: number } {
let maxSlot = 0;
let existingSlot: number | null = null;
for (const entry of config) {
const match = entry.Name.match(/^Category(\d+)\.Name$/);
if (match) {
const slot = parseInt(match[1], 10);
if (slot > maxSlot) {
maxSlot = slot;
}
if (entry.Value === categoryName) {
existingSlot = slot;
}
}
}
return { existingSlot, nextSlot: maxSlot + 1 };
}
/**
* Reload NZBGet so config changes (new categories, DestDir updates) take effect.
* Polls version() to confirm NZBGet is back online before continuing.
*/
private async reloadAndWait(): Promise<void> {
try {
logger.info('Reloading NZBGet to apply configuration changes...');
await this.rpc('reload');
const maxWait = 10000;
const pollInterval = 500;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, pollInterval));
try {
await this.rpc<string>('version');
logger.info('NZBGet reloaded successfully');
return;
} catch {
// Still restarting, keep polling
}
}
logger.warn('NZBGet did not respond after reload within 10s, continuing anyway');
} catch (error) {
logger.warn('NZBGet reload request failed, config changes may require manual restart', {
error: error instanceof Error ? error.message : String(error),
});
}
}
// =========================================================================
// Status Mapping
// =========================================================================
/**
* Map NZBGet queue group item to unified DownloadInfo
*/
private async mapGroupToDownloadInfo(group: NZBGetGroupItem): Promise<DownloadInfo> {
const totalBytes = group.FileSizeMB * 1024 * 1024;
const downloadedBytes = group.DownloadedSizeMB * 1024 * 1024;
const progress = totalBytes > 0 ? Math.min(downloadedBytes / totalBytes, 1.0) : 0;
// Get global download speed for active items
let downloadSpeed = 0;
let eta = 0;
const status = this.mapGroupStatus(group.Status);
if (status === 'downloading') {
try {
const serverStatus = await this.rpc<NZBGetStatus>('status');
downloadSpeed = serverStatus?.DownloadRate || 0;
const remainingBytes = group.RemainingSizeMB * 1024 * 1024;
eta = downloadSpeed > 0 ? Math.round(remainingBytes / downloadSpeed) : 0;
} catch {
// Non-critical: speed/eta will be 0
}
}
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = group.FinalDir || group.DestDir || undefined;
return {
id: String(group.NZBID),
name: group.NZBName,
size: totalBytes,
bytesDownloaded: downloadedBytes,
progress,
status,
downloadSpeed,
eta,
category: group.Category || '',
downloadPath,
completedAt: undefined,
errorMessage: undefined,
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map NZBGet history item to unified DownloadInfo
*/
private mapHistoryToDownloadInfo(history: NZBGetHistoryItem): DownloadInfo {
const totalBytes = history.FileSizeMB * 1024 * 1024;
const downloadedBytes = history.DownloadedSizeMB * 1024 * 1024;
const status = this.mapHistoryStatus(history.Status);
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = history.FinalDir || history.DestDir || undefined;
return {
id: String(history.NZBID),
name: history.Name,
size: totalBytes,
bytesDownloaded: status === 'completed' ? totalBytes : downloadedBytes,
progress: status === 'completed' ? 1.0 : (totalBytes > 0 ? downloadedBytes / totalBytes : 0),
status,
downloadSpeed: 0,
eta: 0,
category: history.Category || '',
downloadPath,
completedAt: history.HistoryTime ? new Date(history.HistoryTime * 1000) : undefined,
errorMessage: status === 'failed' ? this.buildHistoryErrorMessage(history) : undefined,
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map NZBGet queue status string to unified DownloadStatus
*/
private mapGroupStatus(status: string): DownloadStatus {
switch (status) {
case 'QUEUED':
return 'queued';
case 'PAUSED':
return 'paused';
case 'DOWNLOADING':
case 'FETCHING':
return 'downloading';
case 'PP_QUEUED':
case 'LOADING_PARS':
case 'VERIFYING_SOURCES':
case 'REPAIRING':
case 'VERIFYING_REPAIRED':
case 'RENAMING':
case 'UNPACKING':
case 'MOVING':
case 'POST_UNPACK_RENAMING':
case 'EXECUTING_SCRIPT':
case 'PP_FINISHED':
return 'processing';
default:
logger.warn(`Unknown NZBGet queue status: ${status}, defaulting to downloading`);
return 'downloading';
}
}
/**
* Map NZBGet history status string to unified DownloadStatus.
* History statuses have format: "PREFIX/DETAIL" (e.g., "SUCCESS/ALL", "FAILURE/PAR")
*/
private mapHistoryStatus(status: string): DownloadStatus {
const prefix = status.split('/')[0];
switch (prefix) {
case 'SUCCESS':
return 'completed';
case 'WARNING':
// WARNING means the download succeeded but post-processing had issues
// From RMAB's perspective, the download is still completed
return 'completed';
case 'FAILURE':
return 'failed';
case 'DELETED':
return 'failed';
default:
logger.warn(`Unknown NZBGet history status: ${status}, defaulting to failed`);
return 'failed';
}
}
/**
* Build a descriptive error message from NZBGet history item
*/
private buildHistoryErrorMessage(history: NZBGetHistoryItem): string {
const parts: string[] = [];
// Include the raw status for context
parts.push(history.Status);
if (history.ParStatus && history.ParStatus !== 'NONE' && history.ParStatus !== 'SUCCESS') {
parts.push(`Par: ${history.ParStatus}`);
}
if (history.UnpackStatus && history.UnpackStatus !== 'NONE' && history.UnpackStatus !== 'SUCCESS') {
parts.push(`Unpack: ${history.UnpackStatus}`);
}
if (history.DeleteStatus && history.DeleteStatus !== 'NONE') {
parts.push(`Delete: ${history.DeleteStatus}`);
}
// Article failure info
if (history.FailedArticles > 0) {
const failPercent = history.TotalArticles > 0
? Math.round((history.FailedArticles / history.TotalArticles) * 100)
: 0;
parts.push(`${history.FailedArticles} failed articles (${failPercent}%)`);
}
return parts.join(' | ');
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Extract a usable filename for the NZB upload.
* Tries Content-Disposition header first, then URL path, then falls back to a default.
*/
private extractNZBFilename(url: string, contentDisposition?: string): string {
if (contentDisposition) {
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
if (match?.[1]) {
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
if (decoded) {
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
}
}
try {
const urlPath = new URL(url).pathname;
const basename = urlPath.split('/').pop();
if (basename && basename.length > 0 && basename !== 'download') {
const decoded = decodeURIComponent(basename);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
} catch {
// URL parsing failed
}
return 'download.nzb';
}
/**
* Map priority string to NZBGet priority integer.
* NZBGet priorities: -100 (very low), -50 (low), 0 (normal), 50 (high), 100 (very high), 900 (force)
*/
private mapPriority(priority?: string): number {
switch (priority) {
case 'force':
return 900;
case 'high':
return 50;
case 'low':
return -50;
case 'normal':
default:
return 0;
}
}
/**
* Format connection error into a user-friendly message
*/
private formatConnectionError(error: unknown): string {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401) {
return 'Authentication failed. Check your NZBGet username and password (Settings → Security).';
}
if (status === 403) {
return 'Access denied. Check your NZBGet credentials and access permissions.';
}
if (error.code === 'ECONNREFUSED') {
return `Connection refused. Is NZBGet running and accessible at this URL?`;
}
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
return 'Connection timed out. Check the URL and network connectivity.';
}
if (error.message?.includes('certificate') || error.message?.includes('SSL') || error.message?.includes('TLS')) {
return 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.';
}
}
return error instanceof Error ? error.message : 'Unknown error';
}
/**
* Normalize a path for comparison (forward slashes, no trailing slash, lowercase)
*/
private normalizePath(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
}
// =========================================================================
// Singleton Factory
// =========================================================================
let nzbgetServiceInstance: NZBGetService | null = null;
let configLoaded = false;
export async function getNZBGetService(): Promise<NZBGetService> {
if (nzbgetServiceInstance && configLoaded) {
return nzbgetServiceInstance;
}
try {
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
logger.info('Loading configuration from download client manager...');
const clientConfig = await manager.getClientForProtocol('usenet');
if (!clientConfig) {
throw new Error('NZBGet is not configured. Please configure an NZBGet client in the admin settings.');
}
if (clientConfig.type !== 'nzbget') {
throw new Error(`Expected NZBGet client but found ${clientConfig.type}`);
}
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
const pathMappingConfig: PathMappingConfig = {
enabled: clientConfig.remotePathMappingEnabled || false,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
};
logger.info('Config loaded:', {
name: clientConfig.name,
hasUrl: !!clientConfig.url,
hasPassword: !!clientConfig.password,
disableSSLVerify: clientConfig.disableSSLVerify,
downloadDir,
pathMappingEnabled: pathMappingConfig.enabled,
});
if (!clientConfig.url || !clientConfig.password) {
throw new Error('NZBGet is not fully configured. Please check your configuration in admin settings.');
}
nzbgetServiceInstance = new NZBGetService(
clientConfig.url,
clientConfig.username || '',
clientConfig.password,
clientConfig.category || 'readmeabook',
downloadDir,
clientConfig.disableSSLVerify,
pathMappingConfig
);
await nzbgetServiceInstance.ensureCategory();
configLoaded = true;
return nzbgetServiceInstance;
} catch (error) {
logger.error('Failed to initialize service', {
error: error instanceof Error ? error.message : String(error),
});
nzbgetServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateNZBGetService(): void {
nzbgetServiceInstance = null;
configLoaded = false;
logger.info('Service singleton invalidated');
}
+4 -7
View File
@@ -121,11 +121,6 @@ export class ProwlarrService {
filters?: SearchFilters
): Promise<TorrentResult[]> {
try {
// Get configured download client type to determine if we should filter by category
const { getConfigService } = await import('../services/config.service');
const configService = getConfigService();
const clientType = (await configService.get('download_client_type')) || 'qbittorrent';
// Determine which categories to search
// Priority: filters.categories > filters.category > defaultCategory
let categoriesToSearch: number[];
@@ -560,20 +555,22 @@ export class ProwlarrService {
* Extract audiobook metadata from torrent title
*/
private extractMetadata(title: string): {
format?: 'M4B' | 'M4A' | 'MP3';
format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC';
bitrate?: string;
hasChapters?: boolean;
} {
const upperTitle = title.toUpperCase();
// Detect format
let format: 'M4B' | 'M4A' | 'MP3' | undefined;
let format: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | undefined;
if (upperTitle.includes('M4B')) {
format = 'M4B';
} else if (upperTitle.includes('M4A')) {
format = 'M4A';
} else if (upperTitle.includes('MP3')) {
format = 'MP3';
} else if (upperTitle.includes('FLAC')) {
format = 'FLAC';
}
// Detect bitrate (e.g., "64kbps", "128 KBPS")
+219 -17
View File
@@ -5,10 +5,20 @@
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
@@ -59,7 +69,19 @@ export type TorrentState =
| 'checkingUP'
| 'error'
| 'missingFiles'
| 'allocating';
| 'allocating'
// Forced states (user clicked "Force Resume" in qBittorrent UI)
| 'forcedDL'
| 'forcedUP'
// Metadata fetching states
| 'metaDL'
| 'forcedMetaDL'
// qBittorrent v5.0+ renamed paused → stopped
| 'stoppedDL'
| 'stoppedUP'
// Other states
| 'checkingResumeData'
| 'moving';
export interface TorrentFile {
name: string;
@@ -78,7 +100,10 @@ export interface DownloadProgress {
state: string;
}
export class QBittorrentService {
export class QBittorrentService implements IDownloadClient {
readonly clientType: DownloadClientType = 'qbittorrent';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
@@ -209,7 +234,7 @@ export class QBittorrentService {
/**
* Add torrent (magnet link or file URL) - Enterprise Implementation
*/
async addTorrent(url: string, options?: AddTorrentOptions): Promise<string> {
async addTorrent(url: string, options?: AddTorrentOptions, retried = false): Promise<string> {
// Validate URL parameter
if (!url || typeof url !== 'string' || url.trim() === '') {
logger.error('Invalid download URL', { url });
@@ -236,11 +261,11 @@ export class QBittorrentService {
return await this.addTorrentFile(url, category, options);
}
} catch (error) {
// Try re-authenticating if we get a 403
if (axios.isAxiosError(error) && error.response?.status === 403) {
// Try re-authenticating once if we get a 403
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
logger.info('[QBittorrent] Session expired, re-authenticating...');
await this.login();
return this.addTorrent(url, options); // Retry once
return this.addTorrent(url, options, true);
}
logger.error('Failed to add torrent', { error: error instanceof Error ? error.message : String(error) });
@@ -279,12 +304,17 @@ export class QBittorrentService {
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload via 'urls' parameter
// Set ratioLimit and seedingTimeLimit to -1 (unlimited) so qBittorrent's
// global seeding rules don't remove the torrent prematurely.
// RMAB manages torrent lifecycle via the cleanup-seeded-torrents processor.
const form = new URLSearchParams({
urls: magnetUrl,
savepath: remoteSavePath,
category,
paused: options?.paused ? 'true' : 'false',
sequentialDownload: (options?.sequentialDownload !== false).toString(),
ratioLimit: '-1',
seedingTimeLimit: '-1',
});
if (options?.tags) {
@@ -432,6 +462,9 @@ export class QBittorrentService {
formData.append('category', category);
formData.append('paused', options?.paused ? 'true' : 'false');
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
// Override qBittorrent's global seeding rules — RMAB manages torrent lifecycle
formData.append('ratioLimit', '-1');
formData.append('seedingTimeLimit', '-1');
if (options?.tags) {
formData.append('tags', options.tags.join(','));
@@ -729,13 +762,28 @@ export class QBittorrentService {
/**
* Test connection to qBittorrent
*/
async testConnection(): Promise<boolean> {
async testConnection(): Promise<ConnectionTestResult> {
try {
await this.login();
return true;
// Fetch version after successful login
let version: string | undefined;
try {
const versionResponse = await this.client.get('/app/version', {
headers: { Cookie: this.cookie },
});
const raw = versionResponse.data || '';
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
} catch {
// Version fetch is non-critical - connection is still valid
logger.debug('Could not fetch qBittorrent version');
}
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
} catch (error) {
logger.error('Connection test failed', { error: error instanceof Error ? error.message : String(error) });
return false;
const message = error instanceof Error ? error.message : 'Connection failed';
logger.error('Connection test failed', { error: message });
return { success: false, message };
}
}
@@ -835,7 +883,8 @@ export class QBittorrentService {
version: versionResponse.data,
});
return versionResponse.data || 'Connected';
const rawVersion = versionResponse.data || '';
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('[QBittorrent] Test connection failed with axios error', {
@@ -931,6 +980,144 @@ export class QBittorrentService {
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Add a download via the unified interface.
* Delegates to addTorrent with sensible defaults for audiobook downloads.
*/
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
return this.addTorrent(url, {
category: options?.category,
paused: options?.paused,
tags: ['audiobook'],
sequentialDownload: true,
});
}
/**
* Get download status via the unified interface.
* Includes retry logic to handle the race condition where a torrent
* isn't immediately available after being added.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const maxRetries = 3;
const initialDelayMs = 500;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const torrent = await this.getTorrent(id);
return this.mapTorrentToDownloadInfo(torrent);
} catch (error) {
const message = error instanceof Error ? error.message : '';
const isNotFound = message.includes('not found');
// If not a "not found" error, don't retry
if (!isNotFound) {
throw error;
}
// If this is the last attempt, return null
if (attempt === maxRetries) {
return null;
}
// Exponential backoff: 500ms, 1000ms, 2000ms
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return null;
}
/** Pause a download via the unified interface */
async pauseDownload(id: string): Promise<void> {
return this.pauseTorrent(id);
}
/** Resume a download via the unified interface */
async resumeDownload(id: string): Promise<void> {
return this.resumeTorrent(id);
}
/** Delete a download via the unified interface */
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
return this.deleteTorrent(id, deleteFiles);
}
/**
* Post-download cleanup via the unified interface.
* No-op for qBittorrent torrents continue seeding until the
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
*/
async postProcess(_id: string): Promise<void> {
// No-op: torrents are managed by the seeding cleanup scheduler
}
/**
* Map a TorrentInfo object to the unified DownloadInfo format.
*/
private mapTorrentToDownloadInfo(torrent: TorrentInfo): DownloadInfo {
return {
id: torrent.hash,
name: torrent.name,
size: torrent.size,
bytesDownloaded: torrent.downloaded,
progress: torrent.progress,
status: this.mapStateToDownloadStatus(torrent.state),
downloadSpeed: torrent.dlspeed,
eta: torrent.eta,
category: torrent.category,
downloadPath: torrent.content_path || path.join(torrent.save_path, torrent.name),
completedAt: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000) : undefined,
seedingTime: torrent.seeding_time,
ratio: torrent.ratio,
};
}
/**
* Map qBittorrent torrent state to unified DownloadStatus.
*/
private mapStateToDownloadStatus(state: TorrentState): DownloadStatus {
const stateMap: Record<TorrentState, DownloadStatus> = {
downloading: 'downloading',
uploading: 'seeding',
stalledDL: 'downloading',
stalledUP: 'seeding',
pausedDL: 'paused',
pausedUP: 'paused',
queuedDL: 'queued',
queuedUP: 'seeding',
checkingDL: 'checking',
checkingUP: 'checking',
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
// Forced states (user clicked "Force Resume" in qBittorrent UI)
forcedDL: 'downloading',
forcedUP: 'seeding',
// Metadata fetching states
metaDL: 'downloading',
forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused',
stoppedUP: 'paused',
// Other states
checkingResumeData: 'checking',
moving: 'downloading',
};
return stateMap[state] || 'downloading';
}
// =========================================================================
// Legacy Methods (used internally and by direct callers)
// =========================================================================
/**
* Get download progress details
*/
@@ -963,6 +1150,18 @@ export class QBittorrentService {
error: 'failed',
missingFiles: 'failed',
allocating: 'downloading',
// Forced states (user clicked "Force Resume" in qBittorrent UI)
forcedDL: 'downloading',
forcedUP: 'completed',
// Metadata fetching states
metaDL: 'downloading',
forcedMetaDL: 'downloading',
// qBittorrent v5.0+ renamed paused → stopped
stoppedDL: 'paused',
stoppedUP: 'paused',
// Other states
checkingResumeData: 'checking',
moving: 'downloading',
};
return stateMap[state] || 'unknown';
@@ -1032,8 +1231,11 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
throw new Error('qBittorrent is not fully configured. Please check your configuration in admin settings.');
}
// Get download_dir from main config (not part of client config)
const downloadDir = await configService.get('download_dir') || '/downloads';
// Get download_dir from main config, applying customPath if configured
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
// Path mapping configuration
const pathMappingConfig: PathMappingConfig = {
@@ -1055,10 +1257,10 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
// Test connection
logger.info('[QBittorrent] Testing connection...');
const isConnected = await qbittorrentService.testConnection();
if (!isConnected) {
logger.warn('[QBittorrent] Connection test failed');
throw new Error('qBittorrent connection test failed. Please check your configuration in admin settings.');
const connectionResult = await qbittorrentService.testConnection();
if (!connectionResult.success) {
logger.warn('[QBittorrent] Connection test failed', { message: connectionResult.message });
throw new Error(connectionResult.message || 'qBittorrent connection test failed. Please check your configuration in admin settings.');
} else {
logger.info('[QBittorrent] Connection test successful');
configLoaded = true; // Mark as successfully loaded
+234 -25
View File
@@ -5,8 +5,18 @@
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import FormData from 'form-data';
import { RMABLogger } from '@/lib/utils/logger';
import { PathMapper, PathMappingConfig } from '@/lib/utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
const logger = RMABLogger.create('SABnzbd');
@@ -81,7 +91,10 @@ export interface DownloadProgress {
state: string;
}
export class SABnzbdService {
export class SABnzbdService implements IDownloadClient {
readonly clientType: DownloadClientType = 'sabnzbd';
readonly protocol: ProtocolType = 'usenet';
private client: AxiosInstance;
private baseUrl: string;
private apiKey: string;
@@ -123,13 +136,13 @@ export class SABnzbdService {
/**
* Test connection to SABnzbd
*/
async testConnection(): Promise<{ success: boolean; version?: string; error?: string }> {
async testConnection(): Promise<ConnectionTestResult> {
try {
// Validate API key is not empty
if (!this.apiKey || this.apiKey.trim() === '') {
return {
success: false,
error: 'API key is required for SABnzbd',
message: 'API key is required for SABnzbd',
};
}
@@ -151,7 +164,7 @@ export class SABnzbdService {
const errorMsg = response.data?.error || 'Authentication failed';
return {
success: false,
error: errorMsg.includes('API Key')
message: errorMsg.includes('API Key')
? 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).'
: errorMsg,
};
@@ -160,7 +173,7 @@ export class SABnzbdService {
// Queue endpoint requires auth - if we got here, API key is valid
// Now get the version
const version = await this.getVersion();
return { success: true, version };
return { success: true, version, message: `Connected to SABnzbd v${version}` };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -168,28 +181,28 @@ export class SABnzbdService {
if (errorMessage.includes('ECONNREFUSED')) {
return {
success: false,
error: 'Connection refused. Is SABnzbd running and accessible at this URL?',
message: 'Connection refused. Is SABnzbd running and accessible at this URL?',
};
} else if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('ENOTFOUND')) {
return {
success: false,
error: 'Connection timed out. Check the URL and network connectivity.',
message: 'Connection timed out. Check the URL and network connectivity.',
};
} else if (errorMessage.includes('certificate') || errorMessage.includes('SSL') || errorMessage.includes('TLS')) {
return {
success: false,
error: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
message: 'SSL/TLS certificate error. Enable "Disable SSL verification" if using self-signed certificates.',
};
} else if (errorMessage.includes('API Key Incorrect') || errorMessage.includes('API Key Required')) {
return {
success: false,
error: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
message: 'Invalid API key. Check your SABnzbd configuration (Config → General → API Key).',
};
}
return {
success: false,
error: errorMessage,
message: errorMessage,
};
}
}
@@ -447,8 +460,16 @@ export class SABnzbdService {
}
/**
* Add NZB by URL
* Returns the NZB ID
* Add NZB to SABnzbd
*
* Downloads the NZB file content from the source URL (typically a Prowlarr proxy URL)
* and uploads it directly to SABnzbd via mode=addfile. This ensures SABnzbd does not
* need network access to Prowlarr RMAB acts as the intermediary, matching the pattern
* used by qBittorrent for .torrent files.
*
* @param url - NZB download URL (usually a Prowlarr proxy URL)
* @param options - Category, priority, and pause options
* @returns SABnzbd NZB ID (nzo_id)
*/
async addNZB(url: string, options?: AddNZBOptions): Promise<string> {
logger.info(`Adding NZB from URL: ${url.substring(0, 150)}...`);
@@ -459,20 +480,70 @@ export class SABnzbdService {
// This syncs the category path with SABnzbd's complete_dir and handles path mapping
await this.ensureCategory();
const response = await this.client.get('/api', {
params: {
mode: 'addurl',
name: url,
cat: category,
priority: this.mapPriority(options?.priority),
pp: '3', // Post-processing: +Repair, +Unpack, +Delete
output: 'json',
apikey: this.apiKey,
},
// Download the NZB file content from the source URL
// This decouples SABnzbd from needing direct network access to Prowlarr
let nzbBuffer: Buffer;
let filename: string;
try {
logger.info('Downloading NZB file from source URL...');
const nzbResponse = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
// Use the same SSL settings as the SABnzbd client if the NZB URL
// happens to be served over HTTPS with a self-signed cert
httpsAgent: url.startsWith('https') ? this.httpsAgent : undefined,
});
nzbBuffer = Buffer.from(nzbResponse.data);
if (nzbBuffer.length === 0) {
throw new Error('NZB file is empty (0 bytes)');
}
logger.info(`Downloaded NZB file: ${nzbBuffer.length} bytes`);
// Extract filename from Content-Disposition header, URL path, or use fallback
filename = this.extractNZBFilename(url, nzbResponse.headers['content-disposition']);
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status) {
throw new Error(`Failed to download NZB file: HTTP ${status} from source URL`);
}
if (error.code === 'ECONNREFUSED') {
throw new Error('Failed to download NZB file: Connection refused. Is Prowlarr running?');
}
if (error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
throw new Error('Failed to download NZB file: Connection timed out. Check Prowlarr URL and network.');
}
}
throw error;
}
// Upload NZB file content to SABnzbd via mode=addfile (multipart POST)
const formData = new FormData();
formData.append('nzbfile', nzbBuffer, {
filename,
contentType: 'application/x-nzb',
});
formData.append('mode', 'addfile');
formData.append('cat', category);
formData.append('priority', this.mapPriority(options?.priority));
formData.append('pp', '3'); // Post-processing: +Repair, +Unpack, +Delete
formData.append('output', 'json');
formData.append('apikey', this.apiKey);
const response = await this.client.post('/api', formData, {
headers: formData.getHeaders(),
maxBodyLength: Infinity,
maxContentLength: Infinity,
});
if (response.data?.status === false) {
throw new Error(response.data.error || 'Failed to add NZB');
throw new Error(response.data.error || 'Failed to add NZB to SABnzbd');
}
const nzbIds = response.data?.nzo_ids;
@@ -486,6 +557,39 @@ export class SABnzbdService {
return nzbId;
}
/**
* Extract a usable filename for the NZB upload.
* Tries Content-Disposition header first, then URL path, then falls back to a default.
*/
private extractNZBFilename(url: string, contentDisposition?: string): string {
// Try Content-Disposition header (e.g., 'attachment; filename="My.Audiobook.nzb"')
if (contentDisposition) {
const match = contentDisposition.match(/filename[*]?=(?:UTF-8''|"?)([^";]+)/i);
if (match?.[1]) {
const decoded = decodeURIComponent(match[1].replace(/"+$/, ''));
if (decoded) {
logger.debug(`Filename from Content-Disposition: ${decoded}`);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
}
}
// Try extracting from URL path (before query params)
try {
const urlPath = new URL(url).pathname;
const basename = urlPath.split('/').pop();
if (basename && basename.length > 0 && basename !== 'download') {
const decoded = decodeURIComponent(basename);
logger.debug(`Filename from URL path: ${decoded}`);
return decoded.endsWith('.nzb') ? decoded : `${decoded}.nzb`;
}
} catch {
// URL parsing failed, fall through to default
}
return 'download.nzb';
}
/**
* Get NZB info by ID
* Checks queue first, then history
@@ -663,6 +767,108 @@ export class SABnzbdService {
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
/**
* Add a download via the unified interface.
* Delegates to addNZB with mapped options.
*/
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
const priorityMap: Record<string, 'low' | 'normal' | 'high' | 'force'> = {
low: 'low',
normal: 'normal',
high: 'high',
force: 'force',
};
return this.addNZB(url, {
category: options?.category,
priority: options?.priority ? priorityMap[options.priority] || 'normal' : undefined,
paused: options?.paused,
});
}
/**
* Get download status via the unified interface.
* Checks both queue and history to find the NZB.
*/
async getDownload(id: string): Promise<DownloadInfo | null> {
const nzbInfo = await this.getNZB(id);
if (!nzbInfo) {
return null;
}
return this.mapNZBInfoToDownloadInfo(nzbInfo);
}
/** Pause a download via the unified interface */
async pauseDownload(id: string): Promise<void> {
return this.pauseNZB(id);
}
/** Resume a download via the unified interface */
async resumeDownload(id: string): Promise<void> {
return this.resumeNZB(id);
}
/** Delete a download via the unified interface */
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
return this.deleteNZB(id, deleteFiles);
}
/**
* Post-download cleanup via the unified interface.
* Archives the completed NZB from SABnzbd history.
*/
async postProcess(id: string): Promise<void> {
await this.archiveCompletedNZB(id);
}
/**
* Map NZBInfo to the unified DownloadInfo format.
*/
private mapNZBInfoToDownloadInfo(nzb: NZBInfo): DownloadInfo {
return {
id: nzb.nzbId,
name: nzb.name,
size: nzb.size,
bytesDownloaded: Math.round(nzb.size * nzb.progress),
progress: nzb.progress,
status: this.mapNZBStatusToDownloadStatus(nzb.status),
downloadSpeed: nzb.downloadSpeed,
eta: nzb.timeLeft,
category: nzb.category,
downloadPath: nzb.downloadPath,
completedAt: nzb.completedAt,
errorMessage: nzb.errorMessage,
// Usenet has no seeding concept
seedingTime: undefined,
ratio: undefined,
};
}
/**
* Map SABnzbd NZB status to unified DownloadStatus.
*/
private mapNZBStatusToDownloadStatus(status: NZBStatus): DownloadStatus {
const statusMap: Record<NZBStatus, DownloadStatus> = {
downloading: 'downloading',
queued: 'queued',
paused: 'paused',
extracting: 'processing',
completed: 'completed',
failed: 'failed',
repairing: 'processing',
};
return statusMap[status] || 'downloading';
}
// =========================================================================
// Legacy Methods (used internally and by direct callers)
// =========================================================================
/**
* Get download progress from queue item
*/
@@ -796,8 +1002,11 @@ export async function getSABnzbdService(): Promise<SABnzbdService> {
throw new Error(`Expected SABnzbd client but found ${clientConfig.type}`);
}
// Get download_dir from main config
const downloadDir = await configService.get('download_dir') || '/downloads';
// Get download_dir from main config, applying customPath if configured
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
logger.debug('RMAB download_dir from config', { downloadDir });
@@ -0,0 +1,628 @@
/**
* Component: Transmission Integration Service
* Documentation: documentation/phase3/download-clients.md
*/
import axios, { AxiosInstance } from 'axios';
import https from 'https';
import path from 'path';
import * as parseTorrentModule from 'parse-torrent';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import {
IDownloadClient,
DownloadClientType,
ProtocolType,
DownloadInfo,
DownloadStatus,
AddDownloadOptions,
ConnectionTestResult,
} from '../interfaces/download-client.interface';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
const logger = RMABLogger.create('Transmission');
/** Transmission RPC numeric status codes */
type TransmissionStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Transmission torrent fields we request */
interface TransmissionTorrent {
hashString: string;
name: string;
totalSize: number;
downloadedEver: number;
percentDone: number;
status: TransmissionStatus;
rateDownload: number;
eta: number;
labels: string[];
downloadDir: string;
doneDate: number;
errorString: string;
error: number;
secondsSeeding: number;
uploadRatio: number;
uploadedEver: number;
}
/** Fields we request from the Transmission RPC API */
const TORRENT_FIELDS = [
'hashString',
'name',
'totalSize',
'downloadedEver',
'percentDone',
'status',
'rateDownload',
'eta',
'labels',
'downloadDir',
'doneDate',
'errorString',
'error',
'secondsSeeding',
'uploadRatio',
'uploadedEver',
];
export class TransmissionService implements IDownloadClient {
readonly clientType: DownloadClientType = 'transmission';
readonly protocol: ProtocolType = 'torrent';
private client: AxiosInstance;
private baseUrl: string;
private username: string;
private password: string;
private defaultSavePath: string;
private defaultCategory: string;
private disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
private pathMappingConfig: PathMappingConfig;
private sessionId: string = '';
constructor(
baseUrl: string,
username: string,
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
this.password = password;
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
this.httpsAgent = new https.Agent({ rejectUnauthorized: false });
logger.info('[Transmission] SSL certificate verification disabled');
}
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
httpsAgent: this.httpsAgent,
});
}
/**
* Execute an RPC request to Transmission.
* Handles CSRF token (409 capture X-Transmission-Session-Id retry).
*/
private async rpc(method: string, args?: Record<string, any>): Promise<any> {
const body = { method, arguments: args };
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.sessionId) {
headers['X-Transmission-Session-Id'] = this.sessionId;
}
// Add Basic Auth if credentials provided
const auth = this.username
? { username: this.username, password: this.password }
: undefined;
try {
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 409) {
// Capture CSRF token and retry
const newSessionId = error.response.headers['x-transmission-session-id'];
if (newSessionId) {
this.sessionId = newSessionId;
headers['X-Transmission-Session-Id'] = this.sessionId;
const response = await this.client.post('/transmission/rpc', body, { headers, auth });
return response.data;
}
}
throw error;
}
}
// =========================================================================
// IDownloadClient Implementation
// =========================================================================
async testConnection(): Promise<ConnectionTestResult> {
try {
const data = await this.rpc('session-get', { fields: ['version'] });
if (data.result !== 'success') {
return { success: false, message: `Transmission RPC error: ${data.result}` };
}
const version = data.arguments?.version;
return {
success: true,
version,
message: `Connected to Transmission${version ? ` ${version}` : ''}`,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
if (axios.isAxiosError(error)) {
const code = error.code;
const status = error.response?.status;
if (code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
code === 'CERT_HAS_EXPIRED' || code?.includes('CERT') || code?.includes('SSL')) {
return { success: false, message: `SSL certificate verification failed (${code}). Enable "Disable SSL Verification" if you trust this server.` };
}
if (code === 'ECONNREFUSED') {
return { success: false, message: `Connection refused. Check if Transmission is running at: ${this.baseUrl}` };
}
if (code === 'ETIMEDOUT' || code === 'ECONNABORTED') {
return { success: false, message: `Connection timeout. Verify the URL is correct: ${this.baseUrl}` };
}
if (code === 'ENOTFOUND') {
return { success: false, message: `Host not found. Verify the address: ${this.baseUrl}` };
}
if (status === 401) {
return { success: false, message: 'Authentication failed. Check your username and password.' };
}
}
logger.error('Connection test failed', { error: message });
return { success: false, message };
}
}
async addDownload(url: string, options?: AddDownloadOptions): Promise<string> {
if (!url || typeof url !== 'string' || url.trim() === '') {
throw new Error('Invalid download URL: URL is required and must be a non-empty string');
}
const category = options?.category || this.defaultCategory;
if (url.startsWith('magnet:')) {
return this.addMagnetLink(url, category, options);
} else {
return this.addTorrentFile(url, category, options);
}
}
private async addMagnetLink(
magnetUrl: string,
category: string,
options?: AddDownloadOptions
): Promise<string> {
const infoHash = this.extractHashFromMagnet(magnetUrl);
if (!infoHash) {
throw new Error('Invalid magnet link - could not extract info_hash');
}
logger.info(`Extracted info_hash from magnet: ${infoHash}`);
// Check for duplicates
try {
await this.getTorrentByHash(infoHash);
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue
}
const localSavePath = this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
const args: Record<string, any> = {
filename: magnetUrl,
'download-dir': remoteSavePath,
paused: options?.paused || false,
labels: [category],
};
logger.info('[Transmission] Adding magnet link...');
const data = await this.rpc('torrent-add', args);
if (data.result !== 'success') {
throw new Error(`Transmission rejected magnet link: ${data.result}`);
}
// torrent-add returns torrent-added or torrent-duplicate
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
if (!added) {
throw new Error('Transmission did not return torrent info after adding');
}
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
await this.disableSeedLimits(added.hashString || infoHash);
logger.info(`Successfully added magnet link: ${infoHash}`);
return infoHash;
}
private async addTorrentFile(
torrentUrl: string,
category: string,
options?: AddDownloadOptions
): Promise<string> {
logger.info(`Downloading .torrent file from: ${torrentUrl}`);
let torrentResponse;
try {
torrentResponse = await axios.get(torrentUrl, {
responseType: 'arraybuffer',
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 300,
timeout: 30000,
});
// Check if response body is a magnet link
if (torrentResponse.data.length > 0) {
const responseText = torrentResponse.data.toString();
const magnetMatch = responseText.match(/^magnet:\?[^\s]+$/);
if (magnetMatch) {
logger.info('Response body is a magnet link');
return this.addMagnetLink(magnetMatch[0], category, options);
}
}
} catch (error) {
if (!axios.isAxiosError(error) || !error.response) {
throw error;
}
const status = error.response.status;
if (status >= 300 && status < 400) {
const location = error.response.headers['location'];
if (location && location.startsWith('magnet:')) {
return this.addMagnetLink(location, category, options);
}
if (location && (location.startsWith('http://') || location.startsWith('https://'))) {
try {
torrentResponse = await axios.get(location, {
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 5,
});
} catch {
throw new Error('Failed to download torrent file after redirect');
}
} else {
throw new Error(`Invalid redirect location: ${location}`);
}
} else {
throw new Error(`Failed to download torrent: HTTP ${status}`);
}
}
const torrentBuffer = Buffer.from(torrentResponse.data);
let parsedTorrentData: any;
try {
parsedTorrentData = await parseTorrent(torrentBuffer);
} catch {
throw new Error('Invalid .torrent file - failed to parse');
}
const infoHash = parsedTorrentData.infoHash;
if (!infoHash) {
throw new Error('Failed to extract info_hash from .torrent file');
}
logger.info(`Extracted info_hash: ${infoHash}`);
// Check for duplicates
try {
await this.getTorrentByHash(infoHash);
logger.info(`Torrent ${infoHash} already exists (duplicate), returning existing hash`);
return infoHash;
} catch {
// Torrent doesn't exist, continue
}
const localSavePath = this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Transmission accepts base64-encoded .torrent content via 'metainfo' field
const metainfo = torrentBuffer.toString('base64');
const args: Record<string, any> = {
metainfo,
'download-dir': remoteSavePath,
paused: options?.paused || false,
labels: [category],
};
logger.info('[Transmission] Adding .torrent file...');
const data = await this.rpc('torrent-add', args);
if (data.result !== 'success') {
throw new Error(`Transmission rejected .torrent file: ${data.result}`);
}
// torrent-add returns torrent-added or torrent-duplicate
const added = data.arguments?.['torrent-added'] || data.arguments?.['torrent-duplicate'];
// Override Transmission's global seeding rules — RMAB manages torrent lifecycle
await this.disableSeedLimits(added?.hashString || infoHash);
logger.info(`Successfully added torrent: ${infoHash}`);
return infoHash;
}
async getDownload(id: string): Promise<DownloadInfo | null> {
const maxRetries = 3;
const initialDelayMs = 500;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const torrent = await this.getTorrentByHash(id);
return this.mapToDownloadInfo(torrent);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (!message.includes('not found')) {
throw error;
}
if (attempt === maxRetries) {
return null;
}
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${id} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
return null;
}
async pauseDownload(id: string): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-stop', { ids: [torrent.hashString] });
logger.info(`Paused torrent: ${id}`);
} catch (error) {
logger.error('Failed to pause torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to pause torrent');
}
}
async resumeDownload(id: string): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-start', { ids: [torrent.hashString] });
logger.info(`Resumed torrent: ${id}`);
} catch (error) {
logger.error('Failed to resume torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to resume torrent');
}
}
async deleteDownload(id: string, deleteFiles: boolean = false): Promise<void> {
try {
const torrent = await this.getTorrentByHash(id);
await this.rpc('torrent-remove', {
ids: [torrent.hashString],
'delete-local-data': deleteFiles,
});
logger.info(`Deleted torrent: ${id}`);
} catch (error) {
logger.error('Failed to delete torrent', { error: error instanceof Error ? error.message : String(error) });
throw new Error('Failed to delete torrent');
}
}
/**
* Post-download cleanup.
* No-op for Transmission torrents continue seeding until the
* cleanup-seeded-torrents job removes them after meeting seeding requirements.
*/
async postProcess(_id: string): Promise<void> {
// No-op: torrents are managed by the seeding cleanup scheduler
}
// =========================================================================
// Internal Helpers
// =========================================================================
/**
* Disable Transmission's global seed ratio and idle time limits for a torrent.
* Mode 2 = unlimited (ignore global settings). RMAB manages torrent lifecycle
* via the cleanup-seeded-torrents processor using per-indexer seeding times.
*/
private async disableSeedLimits(hashOrId: string): Promise<void> {
try {
await this.rpc('torrent-set', {
ids: [hashOrId],
seedRatioMode: 2,
seedIdleMode: 2,
});
logger.info(`Disabled seed limits for torrent: ${hashOrId}`);
} catch (error) {
// Non-fatal — torrent was still added, just might get cleaned up by Transmission's rules
logger.warn(`Failed to disable seed limits for torrent ${hashOrId}: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get a torrent by its info hash.
*/
private async getTorrentByHash(hash: string): Promise<TransmissionTorrent> {
const data = await this.rpc('torrent-get', { ids: [hash], fields: TORRENT_FIELDS });
if (data.result !== 'success') {
throw new Error(`Transmission RPC error: ${data.result}`);
}
const torrents: TransmissionTorrent[] = data.arguments?.torrents || [];
if (torrents.length === 0) {
throw new Error(`Torrent ${hash} not found`);
}
return torrents[0];
}
/**
* Map Transmission torrent to unified DownloadInfo.
*/
private mapToDownloadInfo(torrent: TransmissionTorrent): DownloadInfo {
// Return raw download path (path mapping is applied downstream by the consumer)
const downloadPath = path.join(torrent.downloadDir, torrent.name);
return {
id: torrent.hashString,
name: torrent.name,
size: torrent.totalSize,
bytesDownloaded: torrent.downloadedEver,
progress: torrent.percentDone,
status: this.mapStatus(torrent.status, torrent.error),
downloadSpeed: torrent.rateDownload,
eta: torrent.eta < 0 ? 0 : torrent.eta,
category: torrent.labels?.[0] || '',
downloadPath,
completedAt: torrent.doneDate > 0 ? new Date(torrent.doneDate * 1000) : undefined,
errorMessage: torrent.error > 0 ? torrent.errorString : undefined,
seedingTime: torrent.secondsSeeding,
ratio: torrent.uploadRatio >= 0 ? torrent.uploadRatio : undefined,
};
}
/**
* Map Transmission numeric status to unified DownloadStatus.
* 0=stopped, 1=check-pending, 2=checking, 3=download-pending,
* 4=downloading, 5=seed-pending, 6=seeding
*/
private mapStatus(status: TransmissionStatus, errorCode: number): DownloadStatus {
if (errorCode > 0) {
return 'failed';
}
const statusMap: Record<TransmissionStatus, DownloadStatus> = {
0: 'paused',
1: 'checking',
2: 'checking',
3: 'queued',
4: 'downloading',
5: 'seeding',
6: 'seeding',
};
return statusMap[status] || 'downloading';
}
/**
* Extract info_hash from magnet link.
*/
private extractHashFromMagnet(magnetUrl: string): string | null {
const match = magnetUrl.match(/xt=urn:btih:([a-fA-F0-9]{40}|[a-zA-Z0-9]{32})/i);
if (match) {
return match[1].toLowerCase();
}
return null;
}
}
// Singleton factory (matches qBittorrent, SABnzbd, NZBGet pattern)
let transmissionServiceInstance: TransmissionService | null = null;
let configLoaded = false;
export async function getTransmissionService(): Promise<TransmissionService> {
if (transmissionServiceInstance && configLoaded) {
return transmissionServiceInstance;
}
try {
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = await getConfigService();
const manager = getDownloadClientManager(configService);
logger.info('[Transmission] Loading configuration from download client manager...');
const clientConfig = await manager.getClientForProtocol('torrent');
if (!clientConfig) {
throw new Error('Transmission is not configured. Please configure a Transmission client in the admin settings.');
}
if (clientConfig.type !== 'transmission') {
throw new Error(`Expected Transmission client but found ${clientConfig.type}`);
}
const baseDir = await configService.get('download_dir') || '/downloads';
const downloadDir = clientConfig.customPath
? require('path').join(baseDir, clientConfig.customPath)
: baseDir;
const pathMappingConfig: PathMappingConfig = {
enabled: clientConfig.remotePathMappingEnabled || false,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
};
logger.info('[Transmission] Config loaded:', {
name: clientConfig.name,
hasUrl: !!clientConfig.url,
hasUsername: !!clientConfig.username,
hasPassword: !!clientConfig.password,
disableSSLVerify: clientConfig.disableSSLVerify,
downloadDir,
pathMappingEnabled: pathMappingConfig.enabled,
});
if (!clientConfig.url) {
throw new Error('Transmission is not fully configured. Please check your configuration in admin settings.');
}
transmissionServiceInstance = new TransmissionService(
clientConfig.url,
clientConfig.username || '',
clientConfig.password || '',
downloadDir,
clientConfig.category || 'readmeabook',
clientConfig.disableSSLVerify,
pathMappingConfig
);
const connectionResult = await transmissionServiceInstance.testConnection();
if (!connectionResult.success) {
throw new Error(connectionResult.message || 'Transmission connection test failed. Please check your configuration in admin settings.');
}
logger.info('[Transmission] Connection test successful');
configLoaded = true;
return transmissionServiceInstance;
} catch (error) {
logger.error('[Transmission] Failed to initialize service', {
error: error instanceof Error ? error.message : String(error),
});
transmissionServiceInstance = null;
configLoaded = false;
throw error;
}
}
export function invalidateTransmissionService(): void {
transmissionServiceInstance = null;
configLoaded = false;
logger.info('[Transmission] Service singleton invalidated');
}
@@ -0,0 +1,180 @@
/**
* Component: Download Client Interface
* Documentation: documentation/phase3/download-clients.md
*
* Defines the contract all download clients must implement.
* Enables protocol-agnostic download management across torrent and usenet clients.
*/
// =========================================================================
// TYPE DEFINITIONS
// =========================================================================
/** Supported download client types — single source of truth */
export const SUPPORTED_CLIENT_TYPES = ['qbittorrent', 'sabnzbd', 'nzbget', 'transmission'] as const;
/** Identifies the specific download client software */
export type DownloadClientType = (typeof SUPPORTED_CLIENT_TYPES)[number];
/** Human-readable display names for each client type */
export const CLIENT_DISPLAY_NAMES: Record<DownloadClientType, string> = {
qbittorrent: 'qBittorrent',
sabnzbd: 'SABnzbd',
nzbget: 'NZBGet',
transmission: 'Transmission',
};
/** Get display name for a client type, falling back to the raw type */
export function getClientDisplayName(type: string): string {
return CLIENT_DISPLAY_NAMES[type as DownloadClientType] || type;
}
/** The download protocol a client operates on */
export type ProtocolType = 'torrent' | 'usenet';
/** Maps each client type to its download protocol */
export const CLIENT_PROTOCOL_MAP: Record<DownloadClientType, ProtocolType> = {
qbittorrent: 'torrent',
sabnzbd: 'usenet',
nzbget: 'usenet',
transmission: 'torrent',
};
/** Unified download status across all clients */
export type DownloadStatus =
| 'downloading'
| 'completed'
| 'seeding'
| 'paused'
| 'queued'
| 'failed'
| 'processing'
| 'checking';
// =========================================================================
// DATA INTERFACES
// =========================================================================
/**
* Unified download information returned by all clients.
* Normalizes torrent and NZB data into a single shape.
*/
export interface DownloadInfo {
/** Client-assigned identifier (torrent hash or NZB ID) */
id: string;
/** Display name of the download */
name: string;
/** Total size in bytes */
size: number;
/** Bytes downloaded so far */
bytesDownloaded: number;
/** Download progress from 0.0 to 1.0 */
progress: number;
/** Normalized download status */
status: DownloadStatus;
/** Current download speed in bytes/sec */
downloadSpeed: number;
/** Estimated time remaining in seconds */
eta: number;
/** Category/label assigned to this download */
category: string;
/** Filesystem path where download is stored (available after completion) */
downloadPath?: string;
/** When the download completed */
completedAt?: Date;
/** Error message if download failed */
errorMessage?: string;
/** Time spent seeding in seconds (torrent clients only) */
seedingTime?: number;
/** Upload/download ratio (torrent clients only) */
ratio?: number;
}
/** Options for adding a new download */
export interface AddDownloadOptions {
/** Category/label to assign */
category?: string;
/** Priority level (interpretation varies by client) */
priority?: string;
/** Whether to add in paused state */
paused?: boolean;
}
/** Result of a connection test */
export interface ConnectionTestResult {
success: boolean;
message?: string;
version?: string;
}
// =========================================================================
// DOWNLOAD CLIENT INTERFACE
// =========================================================================
/**
* IDownloadClient the contract every download client must implement.
*
* Provides a unified API for managing downloads across different protocols
* and client software. Processors interact with this interface exclusively,
* enabling new download clients to be added without modifying consumer code.
*
* To add a new client (e.g., Transmission):
* 1. Create a service class implementing IDownloadClient
* 2. Add the type to DownloadClientType
* 3. Add factory case in DownloadClientManager
*/
export interface IDownloadClient {
/** Identifies the client software (e.g., 'qbittorrent', 'sabnzbd') */
readonly clientType: DownloadClientType;
/** The protocol this client operates on */
readonly protocol: ProtocolType;
/**
* Test the connection to the download client.
* @returns Connection test result with success/failure and optional version
*/
testConnection(): Promise<ConnectionTestResult>;
/**
* Add a new download.
* @param url - Download URL (magnet link, .torrent URL, or .nzb URL)
* @param options - Optional download settings
* @returns Client-assigned download ID (torrent hash or NZB ID)
*/
addDownload(url: string, options?: AddDownloadOptions): Promise<string>;
/**
* Get current status of a download.
* Includes retry logic for race conditions (e.g., torrent not immediately available after adding).
* @param id - Download ID returned by addDownload
* @returns Download info, or null if not found
*/
getDownload(id: string): Promise<DownloadInfo | null>;
/**
* Pause a download.
* @param id - Download ID
*/
pauseDownload(id: string): Promise<void>;
/**
* Resume a paused download.
* @param id - Download ID
*/
resumeDownload(id: string): Promise<void>;
/**
* Delete a download from the client.
* @param id - Download ID
* @param deleteFiles - Whether to also delete downloaded files (default: false)
*/
deleteDownload(id: string, deleteFiles?: boolean): Promise<void>;
/**
* Perform post-download cleanup specific to the client.
* - qBittorrent: No-op (torrents continue seeding, handled by cleanup job)
* - SABnzbd: Archives the completed NZB from history
* @param id - Download ID
*/
postProcess(id: string): Promise<void>;
}
+33
View File
@@ -220,3 +220,36 @@ export async function isLocalAdmin(userId: string): Promise<boolean> {
return user.isSetupAdmin && user.plexId.startsWith('local-');
}
/**
* Middleware: Require setup to be incomplete
* Blocks access to setup-only endpoints after initial setup is finished.
* Returns 403 if setup is already complete, otherwise invokes the handler.
*/
export async function requireSetupIncomplete(
request: NextRequest,
handler: (request: NextRequest) => Promise<NextResponse>
): Promise<NextResponse> {
try {
const config = await prisma.configuration.findUnique({
where: { key: 'setup_completed' },
});
if (config?.value === 'true') {
logger.warn('Setup endpoint called after setup is complete', {
path: request.nextUrl.pathname,
});
return NextResponse.json(
{
error: 'Forbidden',
message: 'Setup has already been completed',
},
{ status: 403 }
);
}
} catch {
// If database is not ready, setup is definitely not complete — allow through
}
return handler(request);
}
@@ -2,11 +2,13 @@
* Component: Cleanup Seeded Torrents Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Cleans up torrents that have met their seeding requirements
* Cleans up downloads that have met their seeding requirements.
* Uses the IDownloadClient interface for client-agnostic operation.
*/
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
export interface CleanupSeededTorrentsPayload {
jobId?: string;
@@ -22,7 +24,9 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
try {
// Get indexer configuration with per-indexer seeding times
const { getConfigService } = await import('../services/config.service');
const { getDownloadClientManager } = await import('../services/download-client-manager.service');
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const indexersConfigStr = await configService.get('prowlarr_indexers');
if (!indexersConfigStr) {
@@ -44,22 +48,28 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
// Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads)
// Find all completed requests + soft-deleted requests (orphaned downloads)
// IMPORTANT: Only cleanup requests that are truly complete and not being actively processed
// NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook)
// Before deleting torrent, we check if other active requests are using it
// NOTE: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded
// NOTE: Ebooks downloaded via indexer search use torrent clients and need seeding cleanup too.
// Direct HTTP ebook downloads are naturally skipped (no torrent hash / unknown client type).
const completedRequests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed)
OR: [
// Active requests that are fully available (scanned by Plex/ABS)
// Audiobook requests that are fully available (matched in Plex/ABS)
{
type: 'audiobook',
status: 'available',
deletedAt: null,
},
// Soft-deleted requests (orphaned downloads)
// We'll check if torrent is shared with active requests before deletion
// Ebook requests that are fully downloaded (terminal state for ebooks)
{
type: 'ebook',
status: 'downloaded',
deletedAt: null,
},
// Soft-deleted requests of any type (orphaned downloads)
{
deletedAt: { not: null },
},
@@ -78,11 +88,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
take: 100, // Limit to 100 requests per run
});
logger.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
logger.info(`Found ${completedRequests.length} requests to check (audiobook: available, ebook: downloaded, or soft-deleted)`);
let cleaned = 0;
let skipped = 0;
let noConfig = 0;
const deletedHashes = new Set<string>(); // Track torrents already deleted this run
for (const request of completedRequests) {
try {
@@ -92,18 +103,27 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue;
}
// Skip SABnzbd downloads - Usenet doesn't have seeding concept
// Skip Usenet downloads - no seeding concept
if (downloadHistory.nzbId && !downloadHistory.torrentHash) {
// For soft-deleted SABnzbd requests, hard delete immediately (no seeding needed)
// For soft-deleted Usenet requests, hard delete immediately (no seeding needed)
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned SABnzbd request ${request.id}`);
logger.info(`Hard-deleted orphaned Usenet request ${request.id}`);
}
continue;
}
// Only process torrent downloads
if (!downloadHistory.torrentHash) {
// Only process downloads that have a client ID
if (!downloadHistory.downloadClientId && !downloadHistory.torrentHash) {
continue;
}
// Determine the download client ID and protocol
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash!;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType}, skipping`);
continue;
}
@@ -126,20 +146,40 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
// Get torrent info from qBittorrent to check seeding time
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
// Skip if this torrent was already deleted earlier in this run
if (deletedHashes.has(clientId.toLowerCase())) {
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (torrent already cleaned this run)`);
}
cleaned++;
continue;
}
let torrent;
// Get download info from the appropriate client via the interface
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (!client) {
logger.warn(`No ${clientType} client configured, skipping request ${request.id}`);
skipped++;
continue;
}
let downloadInfo;
try {
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
downloadInfo = await client.getDownload(clientId);
} catch (error) {
// Torrent might already be deleted, skip
// Download not found in client (already removed), skip
continue;
}
if (!downloadInfo) {
// Download not found in client (already removed)
continue;
}
// Check if seeding time requirement is met
const actualSeedingTime = torrent.seeding_time || 0;
const actualSeedingTime = downloadInfo.seedingTime || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (!hasMetRequirement) {
@@ -148,47 +188,49 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
continue;
}
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
// CRITICAL: Check if any other active (non-deleted) audiobook request is using this same torrent hash
// This prevents deleting shared torrents when user re-requests the same audiobook
const otherActiveRequests = await prisma.request.findMany({
where: {
id: { not: request.id }, // Exclude current request
type: 'audiobook', // Only check audiobook requests
deletedAt: null, // Only check active requests
downloadHistory: {
some: {
torrentHash: downloadHistory.torrentHash,
selected: true,
// CRITICAL: Check if any other active (non-deleted) request is using this same download
const hashToCheck = downloadHistory.torrentHash;
if (hashToCheck) {
const otherActiveRequests = await prisma.request.findMany({
where: {
id: { not: request.id }, // Exclude current request
deletedAt: null, // Only check active requests
downloadHistory: {
some: {
torrentHash: hashToCheck,
selected: true,
},
},
},
},
select: { id: true, status: true },
});
select: { id: true, status: true },
});
if (otherActiveRequests.length > 0) {
logger.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
if (otherActiveRequests.length > 0) {
logger.info(`Skipping download deletion - ${otherActiveRequests.length} other active request(s) still using this download (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
// If this is a soft-deleted request, hard delete it but DON'T delete the torrent
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
// If this is a soft-deleted request, hard delete it but DON'T delete the download
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared download for active requests)`);
}
skipped++;
continue;
}
skipped++;
continue;
}
// Safe to delete - no other active requests using this torrent
await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files
// Safe to delete - no other active requests using this download
await client.deleteDownload(clientId, true); // true = delete files
deletedHashes.add(clientId.toLowerCase());
// If this is a soft-deleted request (orphaned download), hard delete it now
if (request.deletedAt) {
await prisma.request.delete({ where: { id: request.id } });
logger.info(`Hard-deleted orphaned request ${request.id} after torrent cleanup`);
logger.info(`Hard-deleted orphaned request ${request.id} after download cleanup`);
} else {
logger.info(`Deleted torrent and files for active request ${request.id}`);
logger.info(`Deleted download and files for active request ${request.id}`);
}
cleaned++;
@@ -197,7 +239,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
}
}
logger.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
logger.info(`Cleanup complete: ${cleaned} downloads cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
return {
success: true,
@@ -78,7 +78,7 @@ export async function processStartDirectDownload(payload: StartDirectDownloadPay
// Get download configuration
const configService = getConfigService();
const downloadsDir = await configService.get('downloads_dir') || '/downloads';
const downloadsDir = await configService.get('download_dir') || '/downloads';
const baseUrl = await configService.get('ebook_sidecar_base_url') || 'https://annas-archive.li';
const preferredFormat = await configService.get('ebook_sidecar_preferred_format') || 'epub';
const flaresolverrUrl = await configService.get('ebook_sidecar_flaresolverr_url') || undefined;
+62 -130
View File
@@ -5,8 +5,6 @@
import { DownloadTorrentPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { getSABnzbdService } from '../integrations/sabnzbd.service';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { ProwlarrService } from '../integrations/prowlarr.service';
@@ -14,7 +12,7 @@ import { RMABLogger } from '../utils/logger';
/**
* Process download job
* Routes to appropriate download client based on configuration
* Routes to appropriate download client based on protocol detection
* Adds selected result to download client and starts monitoring
*/
export async function processDownloadTorrent(payload: DownloadTorrentPayload): Promise<any> {
@@ -41,151 +39,85 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
},
});
// Detect protocol from result and route to appropriate client
// Detect protocol from result and get appropriate client
const isUsenet = ProwlarrService.isNZBResult(torrent);
const protocol = isUsenet ? 'usenet' : 'torrent';
const config = await getConfigService();
const manager = getDownloadClientManager(config);
const clientConfig = await manager.getClientForProtocol(isUsenet ? 'usenet' : 'torrent');
const client = await manager.getClientServiceForProtocol(protocol);
if (!clientConfig) {
const protocol = isUsenet ? 'Usenet (SABnzbd)' : 'Torrent (qBittorrent)';
throw new Error(`No ${protocol} client configured`);
if (!client) {
throw new Error(`No ${protocol} download client configured. Please add a ${protocol} client in Settings > Download Clients.`);
}
let downloadClientId: string;
let downloadClient: 'qbittorrent' | 'sabnzbd';
// Get client config for category
const clientConfig = await manager.getClientForProtocol(protocol);
const category = clientConfig?.category || 'readmeabook';
if (isUsenet) {
// Route to SABnzbd
logger.info(`Routing to SABnzbd`);
logger.info(`Routing to ${client.clientType} (${client.protocol})`);
const sabnzbd = await getSABnzbdService();
downloadClientId = await sabnzbd.addNZB(torrent.downloadUrl, {
category: clientConfig.category || 'readmeabook',
priority: 'normal',
});
downloadClient = 'sabnzbd';
// Add download via unified interface
const downloadClientId = await client.addDownload(torrent.downloadUrl, {
category,
priority: 'normal',
});
logger.info(`NZB added with ID: ${downloadClientId}`);
logger.info(`Download added with ID: ${downloadClientId}`);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'sabnzbd',
downloadClientId,
torrentName: torrent.title,
nzbId: downloadClientId, // Store NZB ID
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
magnetLink: torrent.downloadUrl, // Download URL (.nzb file)
seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency
leechers: 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
logger.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
downloadHistory.id,
indexerName: torrent.indexer,
indexerId: torrent.indexerId,
downloadClient: client.clientType,
downloadClientId,
'sabnzbd',
3 // Wait 3 seconds before first check
);
torrentName: torrent.title,
// Set protocol-specific ID fields for backward compatibility
torrentHash: client.protocol === 'torrent' ? (torrent.infoHash || downloadClientId) : undefined,
nzbId: client.protocol === 'usenet' ? downloadClientId : undefined,
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl,
magnetLink: torrent.downloadUrl,
seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
logger.info(`Started monitoring job for request ${requestId} (SABnzbd, 3s initial delay)`);
logger.info(`Created download history record: ${downloadHistory.id}`);
return {
success: true,
message: 'NZB added to SABnzbd and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
nzbId: downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
format: torrent.format,
},
};
} else {
// Route to qBittorrent (default)
logger.info(`Routing to qBittorrent`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
downloadClientId,
client.clientType,
3 // Wait 3 seconds before first check
);
const qbt = await getQBittorrentService();
downloadClientId = await qbt.addTorrent(torrent.downloadUrl, {
category: clientConfig.category || 'readmeabook',
tags: ['audiobook'],
sequentialDownload: true,
paused: false,
});
downloadClient = 'qbittorrent';
logger.info(`Started monitoring job for request ${requestId} (${client.clientType}, 3s initial delay)`);
logger.info(`Torrent added with hash: ${downloadClientId}`);
// Create DownloadHistory record
// Determine indexer page URL - exclude magnet links from guid fallback
const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid);
const downloadHistory = await prisma.downloadHistory.create({
data: {
requestId,
indexerName: torrent.indexer,
indexerId: torrent.indexerId, // Store indexer ID for configuration lookup
downloadClient: 'qbittorrent',
downloadClientId,
torrentName: torrent.title,
torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash
torrentSizeBytes: torrent.size,
torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link)
magnetLink: torrent.downloadUrl,
seeders: torrent.seeders || 0,
leechers: torrent.leechers || 0,
downloadStatus: 'downloading',
selected: true,
startedAt: new Date(),
},
});
logger.info(`Created download history record: ${downloadHistory.id}`);
// Trigger monitor download job with initial delay
const jobQueue = getJobQueueService();
await jobQueue.addMonitorJob(
requestId,
downloadHistory.id,
downloadClientId,
'qbittorrent',
3 // Wait 3 seconds before first check to avoid race condition
);
logger.info(`Started monitoring job for request ${requestId} (qBittorrent, 3s initial delay)`);
return {
success: true,
message: 'Torrent added to qBittorrent and monitoring started',
requestId,
downloadHistoryId: downloadHistory.id,
torrentHash: downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders || 0,
format: torrent.format,
},
};
}
return {
success: true,
message: `Download added to ${client.clientType} and monitoring started`,
requestId,
downloadHistoryId: downloadHistory.id,
downloadClientId,
torrent: {
title: torrent.title,
size: torrent.size,
seeders: torrent.seeders || 0,
format: torrent.format,
},
};
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -3,50 +3,13 @@
* Documentation: documentation/phase3/README.md
*/
import path from 'path';
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
/**
* Helper function to retry getTorrent with exponential backoff
* Handles race condition where torrent isn't immediately available after adding
*/
async function getTorrentWithRetry(
qbt: any,
hash: string,
logger: RMABLogger,
maxRetries: number = 3,
initialDelayMs: number = 500
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await qbt.getTorrent(hash);
} catch (error) {
lastError = error as Error;
// If this is the last attempt, throw the error
if (attempt === maxRetries - 1) {
break;
}
// Exponential backoff: 500ms, 1000ms, 2000ms
const delayMs = initialDelayMs * Math.pow(2, attempt);
logger.warn(`Torrent ${hash} not found, retrying in ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
// All retries failed
throw lastError || new Error('Failed to get torrent after retries');
}
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
/**
* Process monitor download job
@@ -59,57 +22,42 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
try {
let progress: any;
let downloadPath: string | undefined;
// Get the download client service via the manager
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[downloadClient as DownloadClientType];
if (!protocol) {
throw new Error(`Unknown download client type: ${downloadClient}`);
}
const client = await manager.getClientServiceForProtocol(protocol);
if (downloadClient === 'qbittorrent') {
// qBittorrent flow
const qbt = await getQBittorrentService();
if (!client) {
throw new Error(`No ${downloadClient} client configured`);
}
// Get torrent status with retry logic (handles race condition)
const torrent = await getTorrentWithRetry(qbt, downloadClientId, logger);
progress = qbt.getDownloadProgress(torrent);
// Get download status via unified interface
const info = await client.getDownload(downloadClientId);
// Store download path for later use
downloadPath = torrent.content_path || path.join(torrent.save_path, torrent.name);
} else if (downloadClient === 'sabnzbd') {
// SABnzbd flow
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
if (!info) {
throw new Error(`Download ${downloadClientId} not found in ${downloadClient}`);
}
// Get NZB status
const nzbInfo = await sabnzbd.getNZB(downloadClientId);
// Build progress object for request updates
const progressPercent = Math.round(info.progress * 100);
const progressState = info.status;
if (!nzbInfo) {
throw new Error(`NZB ${downloadClientId} not found in SABnzbd queue or history`);
}
// Convert NZBInfo to progress format
progress = {
percent: nzbInfo.progress * 100, // Convert 0.0-1.0 to 0-100 (matches qBittorrent format)
bytesDownloaded: nzbInfo.size * nzbInfo.progress,
bytesTotal: nzbInfo.size,
speed: nzbInfo.downloadSpeed,
eta: nzbInfo.timeLeft,
state: nzbInfo.status,
};
// Store download path if available (only set after completion)
downloadPath = nzbInfo.downloadPath;
logger.info(`SABnzbd status: ${nzbInfo.status}`, {
progress: `${(nzbInfo.progress * 100).toFixed(1)}%`,
speed: `${(nzbInfo.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
if (client.protocol === 'usenet') {
logger.info(`${client.clientType} status: ${info.status}`, {
progress: `${(info.progress * 100).toFixed(1)}%`,
speed: `${(info.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s`,
});
} else {
throw new Error(`Download client ${downloadClient} not supported`);
}
// Update request progress
await prisma.request.update({
where: { id: requestId },
data: {
progress: progress.percent,
progress: progressPercent,
updatedAt: new Date(),
},
});
@@ -118,23 +66,21 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: progress.state,
downloadStatus: progressState,
},
});
// Check download state
if (progress.state === 'completed') {
if (progressState === 'completed' || progressState === 'seeding') {
logger.info(`Download completed for request ${requestId}`);
// Ensure we have a download path
const downloadPath = info.downloadPath;
if (!downloadPath) {
throw new Error('Download path not available from download client');
}
// Get path mapping configuration from the specific download client
const configService = getConfigService();
const manager = getDownloadClientManager(configService);
const protocol = downloadClient === 'sabnzbd' ? 'usenet' : 'torrent';
const clientConfig = await manager.getClientForProtocol(protocol);
// Build path mapping config from client settings
@@ -150,17 +96,18 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
const organizePath = PathMapper.transform(downloadPath, pathMappingConfig);
logger.info(`Download completed`, {
downloadClient,
downloadClient: client.clientType,
downloadPath,
organizePath: organizePath !== downloadPath ? `${organizePath} (mapped)` : organizePath,
});
// Update download history to completed
// Update download history to completed (store mapped path for retry reliability)
await prisma.downloadHistory.update({
where: { id: downloadHistoryId },
data: {
downloadStatus: 'completed',
completedAt: new Date(),
downloadPath: organizePath,
},
});
@@ -197,10 +144,10 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
progress: 100,
downloadPath: organizePath,
};
} else if (progress.state === 'failed') {
} else if (progressState === 'failed') {
logger.error(`Download failed for request ${requestId}`);
const errorMessage = 'Download failed in qBittorrent';
const errorMessage = `Download failed in ${client.clientType}`;
// Update request to failed
await prisma.request.update({
@@ -249,7 +196,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
completed: true,
message: 'Download failed',
requestId,
progress: progress.percent,
progress: progressPercent,
};
} else {
// Still downloading - schedule another check in 10 seconds
@@ -263,11 +210,11 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
);
// Only log every 5% progress to reduce log spam
const shouldLog = progress.percent % 5 === 0 || progress.percent < 5;
const shouldLog = progressPercent % 5 === 0 || progressPercent < 5;
if (shouldLog) {
logger.info(`Request ${requestId}: ${progress.percent}% complete (${progress.state})`, {
speed: progress.speed,
eta: progress.eta,
logger.info(`Request ${requestId}: ${progressPercent}% complete (${progressState})`, {
speed: info.downloadSpeed,
eta: info.eta,
});
}
@@ -276,20 +223,20 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
completed: false,
message: 'Download in progress, monitoring continues',
requestId,
progress: progress.percent,
speed: progress.speed,
eta: progress.eta,
state: progress.state,
progress: progressPercent,
speed: info.downloadSpeed,
eta: info.eta,
state: progressState,
};
}
} catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Check if this is a transient "torrent not found" error
// Check if this is a transient "not found" error
const errorMessage = error instanceof Error ? error.message : '';
const isTorrentNotFound = errorMessage.includes('not found') || errorMessage.includes('Torrent') && errorMessage.includes('not found');
const isNotFound = errorMessage.includes('not found');
if (isTorrentNotFound) {
if (isNotFound) {
// Transient error - don't mark request as failed, let Bull retry
// The request stays in 'downloading' status until Bull exhausts all retries
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
+141 -197
View File
@@ -9,6 +9,8 @@ import { getFileOrganizer } from '../utils/file-organizer';
import { RMABLogger } from '../utils/logger';
import { getLibraryService } from '../services/library';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
import { generateFilesHash } from '../utils/files-hash';
import { fixEpubForKindle, cleanupFixedEpub } from '../utils/epub-fixer';
import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
@@ -242,106 +244,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
);
}
// Cleanup Usenet downloads if configured
try {
logger.info('Checking if cleanup is needed for this download');
// Get download history to find NZB ID and indexer
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasNzbId: !!downloadHistory?.nzbId,
hasIndexerId: !!downloadHistory?.indexerId,
nzbId: downloadHistory?.nzbId || 'none',
indexerId: downloadHistory?.indexerId || 'none',
});
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
if (indexersConfig) {
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a Usenet indexer with cleanup enabled
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
// Check if it's a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
// Remove directory and all contents
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
// Remove single file
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories (e.g., empty category folders)
// Get download_dir as the boundary - never delete above this
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
// Note: We only archive from history, not queue. If the NZB is still in the queue
// when we're organizing files, something went wrong with the download monitoring.
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
}
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
return {
success: true,
@@ -592,12 +496,20 @@ async function processEbookOrganization(
const isIndexerDownload = downloadHistory?.downloadClient !== 'direct';
logger.info(`Download source: ${downloadHistory?.downloadClient || 'unknown'} (indexer download: ${isIndexerDownload})`);
// Get file organizer and template
// Get file organizer and ebook-specific template (falls back to audiobook template)
const organizer = await getFileOrganizer();
const templateConfig = await prisma.configuration.findUnique({
where: { key: 'audiobook_path_template' },
const ebookTemplateConfig = await prisma.configuration.findUnique({
where: { key: 'ebook_path_template' },
});
const template = templateConfig?.value || '{author}/{title} {asin}';
let template: string;
if (ebookTemplateConfig?.value) {
template = ebookTemplateConfig.value;
} else {
const audiobookTemplateConfig = await prisma.configuration.findUnique({
where: { key: 'audiobook_path_template' },
});
template = audiobookTemplateConfig?.value || '{author}/{title} {asin}';
}
// Check if Kindle EPUB fix is needed
let effectiveDownloadPath = downloadPath;
@@ -739,99 +651,8 @@ async function processEbookOrganization(
logger.debug(`Ebook library scan disabled (scanEnabled=${scanEnabled})`);
}
// Cleanup Usenet downloads if configured (same logic as audiobooks)
try {
logger.info('Checking if cleanup is needed for ebook download');
// downloadHistory was already fetched earlier in this function
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasNzbId: !!downloadHistory?.nzbId,
hasIndexerId: !!downloadHistory?.indexerId,
nzbId: downloadHistory?.nzbId || 'none',
indexerId: downloadHistory?.indexerId || 'none',
});
if (downloadHistory?.nzbId && downloadHistory?.indexerId) {
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
logger.info(`Indexers config found: ${indexersConfig ? 'yes' : 'no'}`);
if (indexersConfig) {
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a Usenet indexer with cleanup enabled
if (indexer && indexer.protocol?.toLowerCase() !== 'torrent' && indexer.removeAfterProcessing) {
logger.info(`Cleaning up NZB ${downloadHistory.nzbId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
// Check if it's a file or directory
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
// Remove directory and all contents
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
// Remove single file
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories (e.g., empty category folders)
// Get download_dir as the boundary - never delete above this
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
// File/directory might already be deleted or not exist
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then archive from SABnzbd history (hides from UI but preserves for troubleshooting)
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
await sabnzbd.archiveCompletedNZB(downloadHistory.nzbId);
logger.info(`Successfully archived NZB ${downloadHistory.nzbId} and removed files`);
}
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup NZB download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
// Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
return {
success: true,
@@ -932,6 +753,129 @@ async function createEbookRequestIfEnabled(
}
}
// =========================================================================
// DOWNLOAD CLEANUP
// =========================================================================
/**
* Cleanup download files and archive from download client after successful organization.
* Uses the IDownloadClient.postProcess() method for client-specific cleanup (e.g., SABnzbd archive).
* Shared between audiobook and ebook organization flows.
*/
async function cleanupDownloadAfterOrganize(
requestId: string,
downloadPath: string,
configService: any,
jobId: string | undefined,
logger: RMABLogger
): Promise<void> {
try {
logger.info('Checking if cleanup is needed for this download');
// Get download history to find client ID and indexer
const downloadHistory = await prisma.downloadHistory.findFirst({
where: { requestId },
orderBy: { createdAt: 'desc' },
});
logger.info(`Download history found: ${downloadHistory ? 'yes' : 'no'}`, {
hasDownloadClientId: !!downloadHistory?.downloadClientId,
hasIndexerId: !!downloadHistory?.indexerId,
downloadClient: downloadHistory?.downloadClient || 'none',
});
if (!downloadHistory?.indexerId || !downloadHistory?.downloadClientId) {
return;
}
// Get indexer configuration
const indexersConfig = await configService.get('prowlarr_indexers');
if (!indexersConfig) {
return;
}
const indexers: Array<{ id: number; protocol: string; removeAfterProcessing?: boolean }> = JSON.parse(indexersConfig);
const indexer = indexers.find(idx => idx.id === downloadHistory.indexerId);
logger.info(`Indexer found in config: ${indexer ? 'yes' : 'no'}`, {
indexerId: downloadHistory.indexerId,
protocol: indexer?.protocol || 'none',
removeAfterProcessing: indexer?.removeAfterProcessing ?? 'undefined',
});
// Check if this is a non-torrent indexer with cleanup enabled
if (!indexer || indexer.protocol?.toLowerCase() === 'torrent' || !indexer.removeAfterProcessing) {
return;
}
logger.info(`Cleaning up download ${downloadHistory.downloadClientId} (cleanup enabled for indexer ${indexer.id})`);
// First, manually delete files from filesystem
if (downloadPath) {
logger.info(`Removing download files from filesystem: ${downloadPath}`);
const fs = await import('fs/promises');
try {
const stats = await fs.stat(downloadPath);
if (stats.isDirectory()) {
await fs.rm(downloadPath, { recursive: true, force: true });
logger.info(`Removed directory: ${downloadPath}`);
} else {
await fs.unlink(downloadPath);
logger.info(`Removed file: ${downloadPath}`);
}
// Clean up empty parent directories
const downloadDir = await configService.get('download_dir') || '/downloads';
const cleanupResult = await removeEmptyParentDirectories(downloadPath, {
boundaryPath: downloadDir,
logContext: jobId ? { jobId, context: 'CleanupParents' } : undefined,
});
if (cleanupResult.removedDirectories.length > 0) {
logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`);
}
} catch (fsError) {
if ((fsError as NodeJS.ErrnoException).code === 'ENOENT') {
logger.info(`Download path already deleted: ${downloadPath}`);
} else {
throw fsError;
}
}
} else {
logger.warn(`No download path available, skipping filesystem deletion`);
}
// Then use the download client interface for client-specific post-processing
// (e.g., usenet clients archive from history, torrent clients are a no-op)
const clientType = downloadHistory.downloadClient;
if (clientType && clientType !== 'direct') {
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType];
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType}, skipping post-processing`);
return;
}
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (client) {
await client.postProcess(downloadHistory.downloadClientId);
logger.info(`Successfully post-processed download ${downloadHistory.downloadClientId} via ${client.clientType}`);
}
}
} catch (error) {
// Log error but don't fail the job - cleanup is optional
logger.warn(
`Failed to cleanup download: ${error instanceof Error ? error.message : 'Unknown error'}`,
{
error: error instanceof Error ? error.stack : undefined,
}
);
}
}
// =========================================================================
// HELPER FUNCTIONS
// =========================================================================
@@ -2,15 +2,18 @@
* Component: Retry Failed Imports Processor
* Documentation: documentation/backend/services/scheduler.md
*
* Retries file organization for requests that are awaiting import
* Retries file organization for requests that are awaiting import.
* Uses the IDownloadClient interface for client-agnostic path resolution.
*/
import path from 'path';
import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { getJobQueueService } from '../services/job-queue.service';
import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { getDownloadClientManager, DownloadClientManager } from '../services/download-client-manager.service';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { CLIENT_PROTOCOL_MAP, DownloadClientType, ProtocolType } from '../interfaces/download-client.interface';
export interface RetryFailedImportsPayload {
jobId?: string;
@@ -30,7 +33,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
// Helper function to get path mapping config for a specific download client type
const getPathMappingForClient = async (clientType: string): Promise<PathMappingConfig> => {
const protocol = clientType === 'sabnzbd' ? 'usenet' : 'torrent';
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig && clientConfig.remotePathMappingEnabled) {
@@ -43,11 +46,10 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
return { enabled: false, remotePath: '', localPath: '' };
};
// Find all active audiobook requests in awaiting_import status
// Note: Ebook requests use the same organize_files processor but with type branching
// Find all requests in awaiting_import status (both audiobook and ebook)
// The organize_files processor handles both types with type-based branching
const requests = await prisma.request.findMany({
where: {
type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow)
status: 'awaiting_import',
deletedAt: null,
},
@@ -90,111 +92,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
let downloadPath: string;
// Try to get download path from the appropriate download client
// Get path mapping for this specific download client
const clientType = downloadHistory.downloadClient || 'qbittorrent';
const mappingConfig = await getPathMappingForClient(clientType);
if (downloadHistory.torrentHash) {
// qBittorrent download
try {
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
const qbPath = `${torrent.save_path}/${torrent.name}`;
downloadPath = PathMapper.transform(qbPath, mappingConfig);
logger.info(
`Got download path from qBittorrent for request ${request.id}: ${qbPath}` +
(downloadPath !== qbPath ? `${downloadPath} (mapped)` : '')
);
} catch (qbtError) {
// Torrent not found in qBittorrent - try to construct path from config
logger.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
if (!downloadHistory.torrentName) {
logger.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
(downloadPath !== fallbackPath ? `${downloadPath} (mapped)` : '')
);
}
} else if (downloadHistory.nzbId) {
// SABnzbd download
try {
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
if (nzbInfo && nzbInfo.downloadPath) {
downloadPath = PathMapper.transform(nzbInfo.downloadPath, mappingConfig);
logger.info(
`Got download path from SABnzbd for request ${request.id}: ${nzbInfo.downloadPath}` +
(downloadPath !== nzbInfo.downloadPath ? `${downloadPath} (mapped)` : '')
);
} else {
logger.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`);
if (!downloadHistory.torrentName) {
logger.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
(downloadPath !== fallbackPath ? `${downloadPath} (mapped)` : '')
);
}
} catch (sabnzbdError) {
logger.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`);
skipped++;
continue;
}
// Direct downloads (e.g. Anna's Archive ebooks) have no external download client
// Use stored path or construct from download_dir directly
if (clientType === 'direct') {
const noMapping: PathMappingConfig = { enabled: false, remotePath: '', localPath: '' };
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, noMapping, request.id, logger);
} else {
// No download client ID - use fallback path
if (!downloadHistory.torrentName) {
logger.warn(`No download client ID or name for request ${request.id}, skipping`);
// Real download client — resolve path via client API with path mapping
const mappingConfig = await getPathMappingForClient(clientType);
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] as ProtocolType | undefined;
if (!protocol) {
logger.warn(`Unknown download client type: ${clientType} for request ${request.id}, skipping`);
skipped++;
continue;
}
const downloadDir = await configService.get('download_dir');
if (clientId) {
// Try to get path from download client via unified interface
const client = await manager.getClientServiceForProtocol(protocol);
if (!downloadDir) {
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
skipped++;
continue;
if (client) {
try {
const info = await client.getDownload(clientId);
if (info?.downloadPath) {
downloadPath = PathMapper.transform(info.downloadPath, mappingConfig);
logger.info(
`Got download path from ${client.clientType} for request ${request.id}: ${info.downloadPath}` +
(downloadPath !== info.downloadPath ? `${downloadPath} (mapped)` : '')
);
} else {
// Download found but no path — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} catch (clientError) {
// Client error — try stored path, then fallback
logger.warn(`${client.clientType} error for request ${request.id}: ${clientError instanceof Error ? clientError.message : 'Unknown error'}, using fallback path`);
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} else {
// No client configured — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
} else {
// No client ID — try stored path, then fallback
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
}
}
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
logger.info(
`Using configured download path for request ${request.id}: ${configuredPath}` +
(downloadPath !== configuredPath ? `${downloadPath} (mapped)` : '')
);
// Check if we got a valid path (getFallbackPath returns empty string on failure)
if (!downloadPath) {
skipped++;
continue;
}
await jobQueue.addOrganizeJob(
@@ -203,7 +156,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
downloadPath
);
triggered++;
logger.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
} catch (error) {
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
skipped++;
@@ -224,3 +177,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
throw error;
}
}
/**
* Return the stored download path from the database (saved at download completion time).
* Returns empty string if not available (old records won't have this field).
*/
function getStoredPath(
downloadHistory: { downloadPath?: string | null },
requestId: string,
logger: RMABLogger
): string {
if (downloadHistory.downloadPath) {
logger.info(`Using stored download path for request ${requestId}: ${downloadHistory.downloadPath}`);
return downloadHistory.downloadPath;
}
return '';
}
/**
* Construct a fallback download path from config when the download client can't provide one.
* Returns empty string if path cannot be determined (caller should skip the request).
*/
async function getFallbackPath(
downloadHistory: { torrentName: string | null },
configService: any,
mappingConfig: PathMappingConfig,
requestId: string,
logger: RMABLogger,
manager?: DownloadClientManager,
protocol?: ProtocolType
): Promise<string> {
if (!downloadHistory.torrentName) {
logger.warn(`No download name stored for request ${requestId}, cannot construct fallback path, skipping`);
return '';
}
const baseDir = await configService.get('download_dir');
if (!baseDir) {
logger.error(`download_dir not configured, cannot retry request ${requestId}, skipping`);
return '';
}
// Resolve customPath from the client config if available
let downloadDir = baseDir;
if (manager && protocol) {
const clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig?.customPath) {
downloadDir = path.join(baseDir, clientConfig.customPath);
}
}
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
const mappedPath = PathMapper.transform(fallbackPath, mappingConfig);
logger.info(
`Using fallback download path for request ${requestId}: ${fallbackPath}` +
(mappedPath !== fallbackPath ? `${mappedPath} (mapped)` : '')
);
return mappedPath;
}
+7 -2
View File
@@ -243,9 +243,14 @@ async function searchIndexers(
const flagConfigs = flagConfigStr ? JSON.parse(flagConfigStr) : [];
// Group indexers by their EBOOK category configuration
const groups = groupIndexersByCategories(indexersConfig, 'ebook');
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig, 'ebook');
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no ebook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
@@ -58,9 +58,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Group indexers by their category configuration
// This minimizes API calls while ensuring each indexer only searches its configured categories
const groups = groupIndexersByCategories(indexersConfig);
const { groups, skippedIndexers } = groupIndexersByCategories(indexersConfig);
logger.info(`Searching ${indexersConfig.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
if (skippedIndexers.length > 0) {
const skippedNames = skippedIndexers.map(idx => idx.name).join(', ');
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
}
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`);
// Log each group for transparency
groups.forEach((group, index) => {
@@ -2,37 +2,41 @@
* Component: Download Client Manager Service
* Documentation: documentation/phase3/download-clients.md
*
* Manages multiple download clients (qBittorrent, SABnzbd) with protocol-based routing.
* Manages multiple download clients (qBittorrent, Transmission, SABnzbd, NZBGet) with protocol-based routing.
* Supports migration from legacy single-client config to multi-client JSON array format.
*/
import { randomUUID } from 'crypto';
import path from 'path';
import { ConfigurationService } from './config.service';
import { getEncryptionService } from './encryption.service';
import { isEncryptedFormat } from './credential-migration.service';
import { RMABLogger } from '@/lib/utils/logger';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
import { SABnzbdService } from '@/lib/integrations/sabnzbd.service';
import { NZBGetService } from '@/lib/integrations/nzbget.service';
import { TransmissionService } from '@/lib/integrations/transmission.service';
import { PathMappingConfig } from '@/lib/utils/path-mapper';
import { IDownloadClient, DownloadClientType, ProtocolType, CLIENT_PROTOCOL_MAP, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
const logger = RMABLogger.create('DownloadClientManager');
export interface DownloadClientConfig {
id: string;
type: 'qbittorrent' | 'sabnzbd';
type: DownloadClientType;
name: string;
enabled: boolean;
url: string;
username?: string; // qBittorrent only
password: string; // Password (qBittorrent) or API key (SABnzbd)
username?: string; // qBittorrent/Transmission/NZBGet only
password: string; // Password (qBittorrent/Transmission/NZBGet) or API key (SABnzbd)
disableSSLVerify: boolean;
remotePathMappingEnabled: boolean;
remotePath?: string;
localPath?: string;
category?: string; // Default: 'readmeabook'
customPath?: string; // Relative sub-path appended to download_dir
}
type ProtocolType = 'torrent' | 'usenet';
/**
* Download Client Manager
@@ -47,6 +51,7 @@ export class DownloadClientManager {
private static instance: DownloadClientManager | null = null;
private configService: ConfigurationService;
private clientsCache: DownloadClientConfig[] | null = null;
private serviceCache: Map<string, IDownloadClient> = new Map();
private migrationPerformed = false;
private constructor(configService: ConfigurationService) {
@@ -69,6 +74,7 @@ export class DownloadClientManager {
static invalidate(): void {
if (DownloadClientManager.instance) {
DownloadClientManager.instance.clientsCache = null;
DownloadClientManager.instance.serviceCache.clear();
DownloadClientManager.instance.migrationPerformed = false;
logger.debug('Download client cache invalidated');
}
@@ -127,16 +133,17 @@ export class DownloadClientManager {
}
/**
* Get client for specific protocol
* Get client for specific protocol.
* Uses CLIENT_PROTOCOL_MAP so any client type matching the protocol is found
* (e.g. both qBittorrent and Transmission can serve the 'torrent' protocol).
*/
async getClientForProtocol(protocol: ProtocolType): Promise<DownloadClientConfig | null> {
const clients = await this.getAllClients();
const targetType = protocol === 'torrent' ? 'qbittorrent' : 'sabnzbd';
const client = clients.find(c => c.enabled && c.type === targetType);
const client = clients.find(c => c.enabled && CLIENT_PROTOCOL_MAP[c.type] === protocol);
if (!client) {
logger.warn(`No enabled ${targetType} client configured`);
logger.warn(`No enabled ${protocol} client configured`);
return null;
}
@@ -152,36 +159,83 @@ export class DownloadClientManager {
}
/**
* Get instantiated client service for protocol
* Get instantiated client service for protocol.
* Returns the unified IDownloadClient interface for protocol-agnostic usage.
*/
async getClientServiceForProtocol(protocol: ProtocolType): Promise<QBittorrentService | SABnzbdService | null> {
async getClientServiceForProtocol(protocol: ProtocolType): Promise<IDownloadClient | null> {
const client = await this.getClientForProtocol(protocol);
if (!client) {
return null;
}
if (client.type === 'qbittorrent') {
return this.createQBittorrentService(client);
} else {
return this.createSABnzbdService(client);
return this.getOrCreateService(client);
}
/**
* Factory: create a new IDownloadClient from config.
* This is the single place where client type maps to a concrete class.
* Add new client types (e.g. Transmission, NZBGet) here.
*/
private async createService(config: DownloadClientConfig): Promise<IDownloadClient> {
const baseDir = await this.configService.get('download_dir') || '/downloads';
const downloadDir = config.customPath
? path.join(baseDir, config.customPath)
: baseDir;
switch (config.type) {
case 'qbittorrent':
return this.createQBittorrentService(config, downloadDir);
case 'sabnzbd':
return this.createSABnzbdService(config, downloadDir);
case 'nzbget':
return this.createNZBGetService(config, downloadDir);
case 'transmission':
return this.createTransmissionService(config, downloadDir);
default:
throw new Error(`Unsupported download client type: ${config.type}`);
}
}
/**
* Test connection for a specific client config
* Get a cached service instance or create a new one.
* Caches by client config ID to preserve session state (e.g. qBittorrent SID cookie).
*/
private async getOrCreateService(config: DownloadClientConfig): Promise<IDownloadClient> {
const cached = this.serviceCache.get(config.id);
if (cached) {
return cached;
}
const service = await this.createService(config);
this.serviceCache.set(config.id, service);
return service;
}
/**
* Create an IDownloadClient instance from a config object.
* Uses cached instances when available to preserve session state.
*/
async createClientFromConfig(config: DownloadClientConfig): Promise<IDownloadClient> {
return this.getOrCreateService(config);
}
/**
* Test connection for a specific client config.
* Uses the unified IDownloadClient.testConnection() method.
*/
async testConnection(config: DownloadClientConfig): Promise<{ success: boolean; message: string }> {
try {
if (config.type === 'qbittorrent') {
const service = this.createQBittorrentService(config);
await service.testConnection();
return { success: true, message: 'Successfully connected to qBittorrent' };
} else {
const service = this.createSABnzbdService(config);
const version = await service.getVersion();
return { success: true, message: `Successfully connected to SABnzbd (v${version})` };
// Always create a fresh instance for connection testing (don't use cache)
const service = await this.createService(config);
const result = await service.testConnection();
if (result.success) {
const versionSuffix = result.version ? ` (v${result.version})` : '';
return { success: true, message: `Successfully connected to ${config.name}${versionSuffix}` };
}
return { success: false, message: result.message || 'Connection failed' };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error('Connection test failed', { type: config.type, error: message });
@@ -192,7 +246,7 @@ export class DownloadClientManager {
/**
* Create qBittorrent service instance
*/
private createQBittorrentService(config: DownloadClientConfig): QBittorrentService {
private createQBittorrentService(config: DownloadClientConfig, downloadDir: string): QBittorrentService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
@@ -205,8 +259,8 @@ export class DownloadClientManager {
config.url,
config.username || '',
config.password || '', // Optional for IP whitelist auth
'/downloads', // defaultSavePath
config.category || 'readmeabook', // defaultCategory
downloadDir,
config.category || 'readmeabook',
config.disableSSLVerify,
pathMapping
);
@@ -215,7 +269,7 @@ export class DownloadClientManager {
/**
* Create SABnzbd service instance
*/
private createSABnzbdService(config: DownloadClientConfig): SABnzbdService {
private createSABnzbdService(config: DownloadClientConfig, downloadDir: string): SABnzbdService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
@@ -227,8 +281,54 @@ export class DownloadClientManager {
return new SABnzbdService(
config.url,
config.password, // API key stored in password field
config.category || 'readmeabook', // defaultCategory
'/downloads', // defaultDownloadDir (will be overridden by singleton with actual config)
config.category || 'readmeabook',
downloadDir,
config.disableSSLVerify,
pathMapping
);
}
/**
* Create NZBGet service instance
*/
private createNZBGetService(config: DownloadClientConfig, downloadDir: string): NZBGetService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
remotePath: config.remotePath,
localPath: config.localPath,
}
: undefined;
return new NZBGetService(
config.url,
config.username || '',
config.password,
config.category || 'readmeabook',
downloadDir,
config.disableSSLVerify,
pathMapping
);
}
/**
* Create Transmission service instance
*/
private createTransmissionService(config: DownloadClientConfig, downloadDir: string): TransmissionService {
const pathMapping: PathMappingConfig | undefined = config.remotePathMappingEnabled && config.remotePath && config.localPath
? {
enabled: true,
remotePath: config.remotePath,
localPath: config.localPath,
}
: undefined;
return new TransmissionService(
config.url,
config.username || '',
config.password || '',
downloadDir,
config.category || 'readmeabook',
config.disableSSLVerify,
pathMapping
);
@@ -272,8 +372,8 @@ export class DownloadClientManager {
const newClient: DownloadClientConfig = {
id: randomUUID(),
type: clientType as 'qbittorrent' | 'sabnzbd',
name: clientType === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
type: clientType as DownloadClientType,
name: getClientDisplayName(clientType),
enabled: true,
url: clientUrl,
username: clientUsername || undefined,
+3 -2
View File
@@ -7,6 +7,7 @@ import Queue, { Job as BullJob, JobOptions } from 'bull';
import Redis from 'ioredis';
import { prisma } from '../db';
import { TorrentResult } from '../utils/ranking-algorithm';
import { DownloadClientType } from '../interfaces/download-client.interface';
import { RMABLogger } from '../utils/logger';
const logger = RMABLogger.create('JobQueue');
@@ -59,7 +60,7 @@ export interface MonitorDownloadPayload extends JobPayload {
requestId: string;
downloadHistoryId: string;
downloadClientId: string;
downloadClient: 'qbittorrent' | 'sabnzbd';
downloadClient: DownloadClientType;
}
export interface OrganizeFilesPayload extends JobPayload {
@@ -545,7 +546,7 @@ export class JobQueueService {
requestId: string,
downloadHistoryId: string,
downloadClientId: string,
downloadClient: 'qbittorrent' | 'sabnzbd',
downloadClient: DownloadClientType,
delaySeconds: number = 0
): Promise<string> {
return await this.addJob(
+62 -61
View File
@@ -10,6 +10,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { RMABLogger } from '../utils/logger';
import { buildAudiobookPath } from '../utils/file-organizer';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
const logger = RMABLogger.create('RequestDelete');
@@ -119,77 +120,73 @@ export async function deleteRequest(
);
}
// Handle based on download client type (check which ID is present)
if (downloadHistory.torrentHash) {
// qBittorrent download
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
// Handle download cleanup via unified interface
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
const clientType = downloadHistory.downloadClient || 'qbittorrent';
let torrent;
try {
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
} catch (error) {
// Torrent not found in qBittorrent (already removed)
logger.info(`Torrent ${downloadHistory.torrentHash} not found in qBittorrent, skipping`);
}
if (clientId && clientType !== 'direct') {
const { getDownloadClientManager } = await import('./download-client-manager.service');
const manager = getDownloadClientManager(configService);
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet');
if (torrent) {
// Torrent exists in qBittorrent
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
const isCompleted = downloadHistory.downloadStatus === 'completed';
if (client) {
// Get download info to check seeding status
let downloadInfo;
try {
downloadInfo = await client.getDownload(clientId);
} catch (error) {
logger.info(`Download ${clientId} not found in ${clientType}, skipping`);
}
if (isUnlimitedSeeding) {
// Unlimited seeding - keep in qBittorrent, stop monitoring
logger.info(
`Keeping torrent ${torrent.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
);
torrentsKeptUnlimited++;
} else if (!isCompleted) {
// Download not completed - delete immediately
logger.info(
`Deleting incomplete download: ${torrent.name}`
);
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
torrentsRemoved++;
} else {
// Check if seeding requirement is met
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
const actualSeedingTime = torrent.seeding_time || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (downloadInfo) {
const isUnlimitedSeeding = !seedingConfig || seedingConfig.seedingTimeMinutes === 0;
const isCompleted = downloadHistory.downloadStatus === 'completed';
if (hasMetRequirement) {
// Seeding requirement met - delete now
if (client.protocol === 'usenet') {
// Usenet - no seeding concept, delete immediately
try {
await client.deleteDownload(clientId, true);
logger.info(`Deleted download ${clientId} from ${client.clientType}`);
torrentsRemoved++;
} catch (error) {
logger.info(`Download ${clientId} not found in ${client.clientType}, skipping`);
}
} else if (isUnlimitedSeeding) {
// Unlimited seeding - keep in client, stop monitoring
logger.info(
`Deleting torrent ${torrent.name} (seeding complete: ${Math.floor(
actualSeedingTime / 60
)}/${seedingConfig.seedingTimeMinutes} minutes)`
`Keeping download ${downloadInfo.name} for unlimited seeding (indexer: ${downloadHistory.indexerName})`
);
await qbt.deleteTorrent(downloadHistory.torrentHash, true);
torrentsKeptUnlimited++;
} else if (!isCompleted) {
// Download not completed - delete immediately
logger.info(`Deleting incomplete download: ${downloadInfo.name}`);
await client.deleteDownload(clientId, true);
torrentsRemoved++;
} else {
// Still needs seeding - keep for cleanup job
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
logger.info(
`Keeping torrent ${torrent.name} for ${remainingMinutes} more minutes of seeding`
);
torrentsKeptSeeding++;
// Check if seeding requirement is met
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
const actualSeedingTime = downloadInfo.seedingTime || 0;
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
if (hasMetRequirement) {
logger.info(
`Deleting download ${downloadInfo.name} (seeding complete: ${Math.floor(
actualSeedingTime / 60
)}/${seedingConfig.seedingTimeMinutes} minutes)`
);
await client.deleteDownload(clientId, true);
torrentsRemoved++;
} else {
const remainingMinutes = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
logger.info(
`Keeping download ${downloadInfo.name} for ${remainingMinutes} more minutes of seeding`
);
torrentsKeptSeeding++;
}
}
}
}
} else if (downloadHistory.nzbId) {
// SABnzbd download - no seeding concept for Usenet
try {
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
const sabnzbd = await getSABnzbdService();
// Try to delete the NZB from SABnzbd (might already be completed/removed)
await sabnzbd.deleteNZB(downloadHistory.nzbId, true);
logger.info(`Deleted NZB ${downloadHistory.nzbId} from SABnzbd`);
torrentsRemoved++;
} catch (error) {
// NZB not found or already removed
logger.info(`NZB ${downloadHistory.nzbId} not found in SABnzbd, skipping`);
}
}
} catch (error) {
logger.error(
@@ -208,7 +205,11 @@ export async function deleteRequest(
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
const template = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
// Use ebook-specific template for ebook requests, with fallback to audiobook template
const audiobookTemplate = (await configService.get('audiobook_path_template')) || '{author}/{title} {asin}';
const template = isEbook
? (await configService.get('ebook_path_template')) || audiobookTemplate
: audiobookTemplate;
// Fetch year from audible cache if ASIN is available
let year: number | undefined;
+11 -8
View File
@@ -11,11 +11,12 @@ import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { RMABLogger } from './logger';
import { CHAPTER_MERGE_FORMATS } from '../constants/audio-formats';
const execPromise = promisify(exec);
// Supported audio formats for chapter merging
const SUPPORTED_FORMATS = ['.mp3', '.m4a', '.m4b', '.mp4', '.aac'];
// Supported audio formats for chapter merging (from shared constants)
const SUPPORTED_FORMATS: readonly string[] = CHAPTER_MERGE_FORMATS;
// Patterns that indicate chapter-based files
const CHAPTER_PATTERNS = [
@@ -629,9 +630,9 @@ export async function mergeChapters(
await fs.writeFile(metadataFile, chapterMetadata);
await logger?.info(`Generated chapter metadata with ${chapters.length} chapter markers`);
// Determine if we need to re-encode (MP3 input requires conversion to AAC)
// Determine if we need to re-encode (non-AAC input requires conversion to AAC for M4B)
const inputFormat = path.extname(chapters[0].path).toLowerCase();
const needsReencode = inputFormat === '.mp3';
const needsReencode = inputFormat === '.mp3' || inputFormat === '.flac' || inputFormat === '.aac';
// Build ffmpeg command
const args: string[] = [
@@ -646,26 +647,28 @@ export async function mergeChapters(
];
if (needsReencode) {
// MP3 -> M4B requires re-encoding to AAC
// Non-AAC -> M4B requires re-encoding to AAC
const bitrate = determineOutputBitrate(chapters);
// Check for libfdk_aac (higher quality) or fall back to native aac
const hasFdkAac = await checkLibFdkAac();
const formatLabel = inputFormat.slice(1).toUpperCase(); // '.mp3' -> 'MP3', '.flac' -> 'FLAC'
if (hasFdkAac) {
args.push('-c:a', 'libfdk_aac');
args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality)
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`);
} else {
// Use VBR for better quality at same average bitrate
const vbrQuality = bitrateToVbrQuality(bitrate);
args.push('-c:a', 'aac');
args.push('-q:a', vbrQuality.toString());
args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility
await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
await logger?.info(`Merge strategy: Re-encoding ${formatLabel} → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`);
}
} else {
// M4A/M4B -> M4B can use codec copy (fast, lossless)
// M4A/M4B/MP4 -> M4B can use codec copy (fast, lossless)
args.push('-c', 'copy');
await logger?.info(`Merge strategy: Codec copy (lossless, fast - no re-encoding needed for ${inputFormat} input)`);
}
+39 -2
View File
@@ -20,6 +20,7 @@ import {
} from './chapter-merger';
import { prisma } from '../db';
import { substituteTemplate, type TemplateVariables } from './path-template.util';
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
export interface AudiobookMetadata {
title: string;
@@ -362,6 +363,34 @@ export class FileOrganizer {
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger?.error(`Failed to copy ${filename}: ${errorMsg}`);
// If the tagged temp file failed to copy, clean it up and try the original untagged file
if (taggedFilePath) {
// Clean up the tagged temp file that failed to copy
try {
await fs.unlink(taggedFilePath);
await logger?.info(`Cleaned up temp file after copy failure: ${path.basename(taggedFilePath)}`);
} catch {
// Ignore cleanup errors
}
// Fallback: attempt to copy the original untagged file instead
await logger?.info(`Attempting fallback copy of original (untagged) file: ${filename}`);
try {
await fs.access(originalSourcePath, fs.constants.R_OK);
await fs.copyFile(originalSourcePath, targetFilePath);
await fs.chmod(targetFilePath, 0o644);
result.audioFiles.push(targetFilePath);
result.filesMovedCount++;
await logger?.info(`Fallback copy succeeded (without metadata tags): ${filename}`);
result.errors.push(`Tagged copy failed for ${filename}, copied original without metadata tags`);
continue;
} catch (fallbackError) {
const fallbackMsg = fallbackError instanceof Error ? fallbackError.message : 'Unknown error';
await logger?.error(`Fallback copy of original file also failed: ${fallbackMsg}`);
}
}
result.errors.push(`Failed to copy ${audioFile}: ${errorMsg}`);
// Continue with other files instead of throwing
}
@@ -411,7 +440,15 @@ export class FileOrganizer {
// This replaces the old inline ebook sidecar download that happened here.
result.targetPath = targetPath;
result.success = true;
// Only mark as success if at least one audio file was placed in the target directory
// (either freshly copied or already existed from a previous attempt)
if (result.audioFiles.length > 0) {
result.success = true;
} else {
result.errors.push('No audio files were successfully copied to the target directory');
await logger?.error(`Organization failed: no audio files copied despite ${audioFiles.length} file(s) found`);
}
// DO NOT clean up download directory - files needed for seeding
// Cleanup will be handled by the seeding cleanup job after seeding requirements are met
@@ -431,7 +468,7 @@ export class FileOrganizer {
private async findAudiobookFiles(
downloadPath: string
): Promise<{ audioFiles: string[]; coverFile?: string; isFile: boolean }> {
const audioExtensions = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
const audioExtensions: readonly string[] = AUDIO_EXTENSIONS;
const coverPatterns = [
/cover\.(jpg|jpeg|png)$/i,
/folder\.(jpg|jpeg|png)$/i,
+2 -6
View File
@@ -8,11 +8,7 @@
import crypto from 'crypto';
import path from 'path';
/**
* Supported audio file extensions for hash generation
*/
const AUDIO_EXTENSIONS = ['.m4b', '.m4a', '.mp3', '.mp4', '.aa', '.aax'];
import { AUDIO_EXTENSIONS } from '../constants/audio-formats';
/**
* Generates a SHA256 hash of audio filenames for library matching.
@@ -51,7 +47,7 @@ export function generateFilesHash(filePaths: string[]): string {
})
.filter((basename) => {
const ext = path.extname(basename).toLowerCase();
return AUDIO_EXTENSIONS.includes(ext);
return (AUDIO_EXTENSIONS as readonly string[]).includes(ext);
})
.map((basename) => basename.toLowerCase()) // Normalize case
.sort(); // Sort alphabetically for deterministic hash
+33 -29
View File
@@ -5,6 +5,7 @@
* Groups indexers by their category configuration to minimize API calls.
* Indexers with identical categories are grouped together for a single search.
* Supports separate audiobook and ebook category configurations per indexer.
* Indexers with no categories for a given type are skipped (effectively disabled).
*/
export type CategoryType = 'audiobook' | 'ebook';
@@ -25,22 +26,33 @@ export interface IndexerGroup {
indexers: IndexerConfig[];
}
export interface GroupingResult {
groups: IndexerGroup[];
skippedIndexers: IndexerConfig[]; // Indexers skipped due to no categories for the type
}
/**
* Gets the appropriate categories from an indexer based on the category type.
*
* Returns empty array when the field is explicitly set to [] (user disabled this type).
* Falls back to defaults only when the field is undefined/missing (legacy configs).
*
* @param indexer - The indexer configuration
* @param type - The category type ('audiobook' or 'ebook')
* @returns Array of category IDs
* @returns Array of category IDs (empty = disabled for this type)
*/
export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType): number[] {
if (type === 'ebook') {
return indexer.ebookCategories && indexer.ebookCategories.length > 0
? indexer.ebookCategories
: [7020]; // Default ebook category
// Field exists (even if empty) — respect it
if (Array.isArray(indexer.ebookCategories)) {
return indexer.ebookCategories;
}
// Field missing — legacy config, use default
return [7020];
}
// Audiobook - check new field first, then legacy field
if (indexer.audiobookCategories && indexer.audiobookCategories.length > 0) {
// Audiobook check new field first, then legacy field
if (Array.isArray(indexer.audiobookCategories)) {
return indexer.audiobookCategories;
}
if (indexer.categories && indexer.categories.length > 0) {
@@ -52,57 +64,49 @@ export function getCategoriesForType(indexer: IndexerConfig, type: CategoryType)
/**
* Groups indexers by their category configuration.
* Indexers with identical category arrays are grouped together.
* Indexers with no categories for the specified type are skipped.
*
* @param indexers - Array of indexer configurations
* @param type - The category type to group by ('audiobook' or 'ebook')
* @returns Array of groups, each containing indexers with matching categories
* @returns GroupingResult with groups and skipped indexers
*
* @example
* const indexers = [
* { id: 1, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [7020] },
* { id: 2, audiobookCategories: [3030], ebookCategories: [] },
* { id: 3, audiobookCategories: [3030, 3010], ebookCategories: [7020] },
* ];
*
* const audiobookGroups = groupIndexersByCategories(indexers, 'audiobook');
* // Result:
* // [
* // { categories: [3030], indexerIds: [1, 2], indexers: [...] },
* // { categories: [3030, 3010], indexerIds: [3], indexers: [...] }
* // ]
*
* const ebookGroups = groupIndexersByCategories(indexers, 'ebook');
* // Result:
* // [
* // { categories: [7020], indexerIds: [1, 2, 3], indexers: [...] }
* // ]
* const result = groupIndexersByCategories(indexers, 'ebook');
* // result.groups: [{ categories: [7020], indexerIds: [1, 3], indexers: [...] }]
* // result.skippedIndexers: [{ id: 2, ... }] (no ebook categories)
*/
export function groupIndexersByCategories(
indexers: IndexerConfig[],
type: CategoryType = 'audiobook'
): IndexerGroup[] {
// Map to track unique category combinations
// Key: sorted category IDs as string (e.g., "3030,3010")
// Value: array of indexers with those categories
): GroupingResult {
const groupMap = new Map<string, IndexerConfig[]>();
const skippedIndexers: IndexerConfig[] = [];
for (const indexer of indexers) {
// Get categories for the specified type
const categories = getCategoriesForType(indexer, type);
// Skip indexers with no categories for this type (effectively disabled)
if (categories.length === 0) {
skippedIndexers.push(indexer);
continue;
}
// Sort categories to ensure consistent grouping
// [3030, 3010] and [3010, 3030] should be the same group
const sortedCategories = [...categories].sort((a, b) => a - b);
const key = sortedCategories.join(',');
// Add indexer to group
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(indexer);
}
// Convert map to array of groups
const groups: IndexerGroup[] = [];
for (const [key, indexersInGroup] of groupMap.entries()) {
const categories = key.split(',').map(Number);
@@ -115,7 +119,7 @@ export function groupIndexersByCategories(
});
}
return groups;
return { groups, skippedIndexers };
}
/**
+28 -2
View File
@@ -7,6 +7,7 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import { METADATA_TAG_FORMATS, MP4_CONTAINER_FORMATS } from '../constants/audio-formats';
const execPromise = promisify(exec);
@@ -41,7 +42,7 @@ export async function tagAudioFileMetadata(
const ext = path.extname(filePath).toLowerCase();
// Only process supported formats
if (!['.m4b', '.m4a', '.mp3', '.mp4'].includes(ext)) {
if (!(METADATA_TAG_FORMATS as readonly string[]).includes(ext)) {
return {
success: false,
filePath,
@@ -61,7 +62,7 @@ export async function tagAudioFileMetadata(
];
// For m4b/m4a/mp4 files, use standard metadata tags
if (['.m4b', '.m4a', '.mp4'].includes(ext)) {
if ((MP4_CONTAINER_FORMATS as readonly string[]).includes(ext)) {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`, // Book title in Album field (Plex uses this)
@@ -85,6 +86,31 @@ export async function tagAudioFileMetadata(
// Explicitly specify output format (fixes .tmp extension issue)
args.push('-f', 'mp4');
}
// For FLAC files, use Vorbis comment tags (native FLAC metadata)
else if (ext === '.flac') {
args.push(
'-metadata', `title="${escapeMetadata(metadata.title)}"`,
'-metadata', `album="${escapeMetadata(metadata.title)}"`,
'-metadata', `albumartist="${escapeMetadata(metadata.author)}"`,
'-metadata', `artist="${escapeMetadata(metadata.author)}"`
);
if (metadata.narrator) {
args.push('-metadata', `composer="${escapeMetadata(metadata.narrator)}"`);
}
if (metadata.year) {
args.push('-metadata', `date="${metadata.year}"`);
}
if (metadata.asin) {
// FLAC supports arbitrary Vorbis comment tags
args.push('-metadata', `ASIN="${escapeMetadata(metadata.asin)}"`);
}
// Explicitly specify output format
args.push('-f', 'flac');
}
// For mp3 files, use ID3v2 tags
else if (ext === '.mp3') {
args.push(
+57
View File
@@ -0,0 +1,57 @@
/**
* Utility: Permission Resolution
* Documentation: documentation/admin-dashboard.md
*
* Resolves effective user permissions from the tri-state pattern:
* admin always granted
* per-user setting (true/false) explicit override
* null falls back to global setting
*/
import { prisma } from '@/lib/db';
/**
* Resolve a tri-state permission (admin per-user global fallback).
* @param userRole - 'admin' or 'user'
* @param userValue - per-user setting (true, false, or null)
* @param globalValue - global setting from Configuration table
* @returns effective boolean permission
*/
export function resolvePermission(
userRole: string,
userValue: boolean | null,
globalValue: boolean
): boolean {
if (userRole === 'admin') return true;
if (userValue === true) return true;
if (userValue === false) return false;
return globalValue;
}
/**
* Fetch a global boolean setting from the Configuration table.
* @param key - Configuration key
* @param defaultValue - Value to use if the key doesn't exist
*/
export async function getGlobalBooleanSetting(
key: string,
defaultValue: boolean = true
): Promise<boolean> {
const config = await prisma.configuration.findUnique({
where: { key },
});
return config == null ? defaultValue : config.value === 'true';
}
/**
* Resolve a user's effective interactive search access permission.
*/
export async function resolveInteractiveSearchAccess(
userRole: string,
userInteractiveSearchAccess: boolean | null
): Promise<boolean> {
if (userRole === 'admin') return true;
if (userInteractiveSearchAccess === true) return true;
if (userInteractiveSearchAccess === false) return false;
return getGlobalBooleanSetting('interactive_search_access', true);
}
+28 -8
View File
@@ -17,7 +17,7 @@ export interface TorrentResult {
infoUrl?: string; // Link to indexer's info page (for user reference)
infoHash?: string;
guid: string;
format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER';
format?: 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER';
bitrate?: string;
hasChapters?: boolean;
flags?: string[]; // Indexer flags like "Freeleech", "Internal", etc.
@@ -254,6 +254,7 @@ export class RankingAlgorithm {
* Reduced from 25 to make room for data-driven size scoring
* M4B with chapters: 10 pts
* M4B without chapters: 9 pts
* FLAC: 7 pts (lossless audio, excellent quality)
* M4A: 6 pts
* MP3: 4 pts
* Other: 1 pt
@@ -264,6 +265,8 @@ export class RankingAlgorithm {
switch (format) {
case 'M4B':
return torrent.hasChapters !== false ? 10 : 9;
case 'FLAC':
return 7;
case 'M4A':
return 6;
case 'MP3':
@@ -395,11 +398,13 @@ export class RankingAlgorithm {
.filter(word => word.length > 0 && !stopList.includes(word));
};
// Separate required words (outside parentheses/brackets) from optional words (inside)
// This handles common patterns like "Title (Subtitle)" where subtitle may be omitted
// Note: Run on ORIGINAL title to preserve brackets, then normalize the result
// Separate required words (outside parentheses/brackets/colon subtitles) from optional words
// This handles common patterns like:
// "Title (Subtitle)" where subtitle may be omitted
// "Title: Series Name" where Audible appends series names after a colon
// Note: Run on ORIGINAL title to preserve brackets/colons, then normalize the result
const separateRequiredOptional = (title: string): { required: string; optional: string } => {
// Work with original title format for bracket detection
// Work with original title format for bracket/colon detection
const originalTitle = audiobook.title.toLowerCase();
// Extract content in parentheses/brackets as optional
@@ -411,8 +416,20 @@ export class RankingAlgorithm {
optionalMatches.push(match[1]);
}
// Remove parenthetical/bracketed content to get required portion
const requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
// Remove parenthetical/bracketed content to get the non-bracketed portion
let requiredRaw = originalTitle.replace(/[(\[{][^)\]}]+[)\]}]/g, ' ').trim();
// Treat content after a colon as optional (Audible commonly appends series names)
// e.g., "The Finest Edge of Twilight: Dungeons & Dragons" → required: title, optional: series
const colonIndex = requiredRaw.indexOf(':');
if (colonIndex > 0 && colonIndex < requiredRaw.length - 1) {
const afterColon = requiredRaw.substring(colonIndex + 1).trim();
if (afterColon.length > 0) {
optionalMatches.push(afterColon);
}
requiredRaw = requiredRaw.substring(0, colonIndex).trim();
}
// Normalize the required portion (handles CamelCase, punctuation)
const required = this.normalizeForMatching(requiredRaw);
const optional = optionalMatches.join(' ');
@@ -652,7 +669,7 @@ export class RankingAlgorithm {
/**
* Detect format from torrent title
*/
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'OTHER' {
private detectFormat(torrent: TorrentResult): 'M4B' | 'M4A' | 'MP3' | 'FLAC' | 'OTHER' {
// Use explicit format if provided
if (torrent.format) {
return torrent.format;
@@ -664,6 +681,7 @@ export class RankingAlgorithm {
if (title.includes('M4B')) return 'M4B';
if (title.includes('M4A')) return 'M4A';
if (title.includes('MP3')) return 'MP3';
if (title.includes('FLAC')) return 'FLAC';
// Default to OTHER if no format detected
return 'OTHER';
@@ -686,6 +704,8 @@ export class RankingAlgorithm {
if (torrent.hasChapters !== false) {
notes.push('Has chapter markers');
}
} else if (format === 'FLAC') {
notes.push('Lossless format (FLAC)');
} else if (format === 'M4A') {
notes.push('Good format (M4A)');
} else if (format === 'MP3') {
+23
View File
@@ -80,3 +80,26 @@ export function isParentCategory(categoryId: number): boolean {
const category = TORRENT_CATEGORIES.find((cat) => cat.id === categoryId);
return !!category?.children && category.children.length > 0;
}
/**
* Get all standard category IDs (parents and children) from the predefined tree
*/
export function getAllStandardCategoryIds(): Set<number> {
const ids = new Set<number>();
for (const parent of TORRENT_CATEGORIES) {
ids.add(parent.id);
if (parent.children) {
for (const child of parent.children) {
ids.add(child.id);
}
}
}
return ids;
}
/**
* Check if a category ID exists in the predefined category tree
*/
export function isStandardCategory(categoryId: number): boolean {
return getAllStandardCategoryIds().has(categoryId);
}