Merge branch 'main' into ebook-piecewise

This commit is contained in:
kikootwo
2026-02-02 10:33:20 -05:00
15 changed files with 1271 additions and 376 deletions
+21 -2
View File
@@ -9,7 +9,7 @@ Comprehensive overview of system metrics, active requests, download monitoring,
- **Metrics:** Total requests, active downloads, completed/failed requests, total users, system health
- **Requests Awaiting Approval:** Grid of requests pending admin approval (approve/deny buttons, auto-refresh)
- **Active Downloads:** Real-time table with title, progress, speed, ETA
- **Recent Requests:** Last 50 with status and timestamps
- **Request Management:** Full-featured table with filtering, sorting, pagination
- **Quick Actions:** Links to settings, users, scheduled jobs, system logs
## Data Sources
@@ -25,8 +25,15 @@ Comprehensive overview of system metrics, active requests, download monitoring,
**GET /api/admin/downloads/active**
- Request ID, title, progress %, speed, ETA, user
**GET /api/admin/requests/recent**
**GET /api/admin/requests** (Paginated)
- Query params: `page`, `pageSize` (10|25|50|100), `search`, `status`, `userId`, `sortBy`, `sortOrder`
- Returns: `requests[]`, `total`, `page`, `pageSize`, `totalPages`
- Sorting: createdAt (default), completedAt, title, user, status
- Filtering: by status, by user, text search (title/author)
**GET /api/admin/requests/recent** (Legacy)
- Request ID, title, user, status, created/completed dates
- Limited to 50 entries, no filtering
**GET /api/admin/requests/pending-approval**
- Requests with status 'awaiting_approval', includes audiobook + user details
@@ -54,6 +61,18 @@ Comprehensive overview of system metrics, active requests, download monitoring,
- Returns: Job logs with request/audiobook/user details, pagination info
- Filters: status (all/pending/active/completed/failed/delayed/stuck), type (all job types)
## Request Management Features
- **Filter Bar:**
- Text search (title/author, 300ms debounce)
- Status dropdown (all statuses)
- User dropdown (all users)
- Clear filters button
- **Sortable Columns:** Click headers to sort by title, user, status, requested, completed
- **Pagination:** Page navigation, page size selector (10/25/50/100), results count
- **URL State:** Filters/sort/page stored in URL query params (shareable, bookmarkable)
- **Actions:** Delete, cancel, manual search, fetch ebook (via dropdown)
## Features
- Auto-refresh every 10 seconds (SWR)
+489 -133
View File
@@ -1,16 +1,18 @@
/**
* Component: Admin Recent Requests Table
* Component: Admin Requests Management Table
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { formatDistanceToNow } from 'date-fns';
import useSWR from 'swr';
import { ConfirmDialog } from './ConfirmDialog';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { mutate } from 'swr';
import { fetchWithAuth } from '@/lib/utils/api';
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast';
interface RecentRequest {
@@ -19,6 +21,7 @@ interface RecentRequest {
author: string;
status: string;
type?: 'audiobook' | 'ebook';
userId: string;
user: string;
createdAt: Date;
completedAt: Date | null;
@@ -26,14 +29,50 @@ interface RecentRequest {
torrentUrl?: string | null;
}
interface RecentRequestsTableProps {
interface User {
id: string;
plexUsername: string;
}
interface RequestsResponse {
requests: RecentRequest[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface RecentRequestsTableProps {
ebookSidecarEnabled?: boolean;
}
const STATUS_OPTIONS = [
{ value: 'all', label: 'All Statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'awaiting_approval', label: 'Awaiting Approval' },
{ value: 'awaiting_search', label: 'Awaiting Search' },
{ value: 'searching', label: 'Searching' },
{ value: 'downloading', label: 'Downloading' },
{ value: 'processing', label: 'Processing' },
{ value: 'downloaded', label: 'Downloaded' },
{ value: 'awaiting_import', label: 'Awaiting Import' },
{ value: 'available', label: 'Available' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'warn', label: 'Warning' },
{ value: 'cancelled', label: 'Cancelled' },
{ value: 'denied', label: 'Denied' },
];
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
type SortField = 'createdAt' | 'completedAt' | 'title' | 'user' | 'status';
type SortOrder = 'asc' | 'desc';
function getStatusBadge(status: string) {
const styles: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
@@ -45,6 +84,7 @@ function getStatusBadge(status: string) {
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
warn: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
denied: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
const style = styles[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
@@ -52,6 +92,7 @@ function getStatusBadge(status: string) {
const labels: Record<string, string> = {
awaiting_search: 'Awaiting Search',
awaiting_import: 'Awaiting Import',
awaiting_approval: 'Awaiting Approval',
};
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
@@ -65,7 +106,45 @@ function getStatusBadge(status: string) {
);
}
export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: RecentRequestsTableProps) {
function SortIcon({ field, currentSort, currentOrder }: { field: SortField; currentSort: SortField; currentOrder: SortOrder }) {
if (field !== currentSort) {
return (
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
return currentOrder === 'asc' ? (
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
) : (
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const toast = useToast();
// Get filter state from URL
const page = parseInt(searchParams.get('page') || '1', 10);
const pageSize = parseInt(searchParams.get('pageSize') || '25', 10);
const search = searchParams.get('search') || '';
const status = searchParams.get('status') || 'all';
const userId = searchParams.get('userId') || '';
const sortBy = (searchParams.get('sortBy') || 'createdAt') as SortField;
const sortOrder = (searchParams.get('sortOrder') || 'desc') as SortOrder;
// Local search input state for debouncing
const [searchInput, setSearchInput] = useState(search);
// Dialog states
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<{
id: string;
@@ -73,8 +152,74 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
} | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isFetchingEbook, setIsFetchingEbook] = useState(false);
const toast = useToast();
// Build API URL with current filters
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(search)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
// Fetch requests with SWR
const { data, error, isLoading } = useSWR<RequestsResponse>(apiUrl, authenticatedFetcher, {
refreshInterval: 10000,
});
// Fetch users for filter dropdown
const { data: usersData } = useSWR<{ users: User[] }>('/api/admin/users', authenticatedFetcher);
// Update URL with new params
const updateParams = useCallback(
(updates: Record<string, string | number>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === '' || value === 'all' || (key === 'page' && value === 1) || (key === 'pageSize' && value === 25)) {
params.delete(key);
} else {
params.set(key, String(value));
}
});
// Reset to page 1 when filters change (except when changing page itself)
if (!('page' in updates)) {
params.delete('page');
}
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname;
router.push(newUrl, { scroll: false });
},
[pathname, router, searchParams]
);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
if (searchInput !== search) {
updateParams({ search: searchInput });
}
}, 300);
return () => clearTimeout(timer);
}, [searchInput, search, updateParams]);
// Sync search input with URL param on mount
useEffect(() => {
setSearchInput(search);
}, [search]);
const handleSort = (field: SortField) => {
if (field === sortBy) {
updateParams({ sortOrder: sortOrder === 'asc' ? 'desc' : 'asc' });
} else {
updateParams({ sortBy: field, sortOrder: 'desc' });
}
};
const clearFilters = () => {
setSearchInput('');
router.push(pathname, { scroll: false });
};
const hasActiveFilters = search || status !== 'all' || userId;
// Action handlers
const handleDeleteClick = (requestId: string, title: string) => {
setSelectedRequest({ id: requestId, title });
setShowDeleteConfirm(true);
@@ -98,21 +243,14 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
throw new Error(errorData.message || 'Failed to delete request');
}
const result = await response.json();
// Show success message
console.log('[Admin] Request deleted:', result);
// Refresh the requests list
await mutate('/api/admin/requests/recent');
await mutate(apiUrl);
await mutate('/api/admin/metrics');
// Invalidate audiobook caches to update request status on home/search pages
await mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks'));
// Close dialog
setShowDeleteConfirm(false);
setSelectedRequest(null);
toast.success('Request deleted successfully');
} catch (error) {
console.error('[Admin] Failed to delete request:', error);
toast.error(`Failed to delete request: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -140,9 +278,8 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
throw new Error(errorData.message || 'Failed to trigger manual search');
}
console.log('[Admin] Manual search triggered for request:', requestId);
// Refresh the requests list
await mutate('/api/admin/requests/recent');
toast.success('Manual search triggered');
await mutate(apiUrl);
} catch (error) {
console.error('[Admin] Failed to trigger manual search:', error);
toast.error(`Failed to trigger manual search: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -164,9 +301,8 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
throw new Error(errorData.message || 'Failed to cancel request');
}
console.log('[Admin] Request cancelled:', requestId);
// Refresh the requests list
await mutate('/api/admin/requests/recent');
toast.success('Request cancelled');
await mutate(apiUrl);
} catch (error) {
console.error('[Admin] Failed to cancel request:', error);
toast.error(`Failed to cancel request: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -183,16 +319,16 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
},
});
const data = await response.json();
const responseData = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to fetch e-book');
throw new Error(responseData.error || responseData.message || 'Failed to fetch e-book');
}
if (data.success) {
toast.success(data.message || 'E-book fetched successfully');
if (responseData.success) {
toast.success(responseData.message || 'E-book fetched successfully');
} else {
toast.warning(`E-book fetch failed: ${data.message}`);
toast.warning(`E-book fetch failed: ${responseData.message}`);
}
} catch (error) {
console.error('[Admin] Failed to fetch e-book:', error);
@@ -202,16 +338,104 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
}
};
if (requests.length === 0) {
// Render loading state
if (isLoading && !data) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
<div className="text-center">
<div className="text-gray-400 dark:text-gray-600 mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
);
}
// Render error state
if (error) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
<div className="text-center text-red-600 dark:text-red-400">
Failed to load requests. Please try again.
</div>
</div>
);
}
const requests = data?.requests || [];
const total = data?.total || 0;
const totalPages = data?.totalPages || 1;
// Calculate display range
const startIndex = (page - 1) * pageSize + 1;
const endIndex = Math.min(page * pageSize, total);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Filter Bar */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div className="flex flex-col sm:flex-row gap-3">
{/* Search Input */}
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search by title or author..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
</div>
{/* Status Filter */}
<select
value={status}
onChange={(e) => updateParams({ status: e.target.value })}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm min-w-[160px]"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{/* User Filter */}
<select
value={userId}
onChange={(e) => updateParams({ userId: e.target.value })}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm min-w-[160px]"
>
<option value="">All Users</option>
{usersData?.users.map((user) => (
<option key={user.id} value={user.id}>
{user.plexUsername}
</option>
))}
</select>
{/* Clear Filters Button */}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear
</button>
)}
</div>
</div>
{/* Table */}
{requests.length === 0 ? (
<div className="p-8 text-center">
<div className="text-gray-400 dark:text-gray-600 mb-2">
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fillRule="evenodd"
@@ -221,115 +445,247 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
No Recent Requests
{hasActiveFilters ? 'No Matching Requests' : 'No Requests'}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
No audiobook requests have been made yet.
{hasActiveFilters
? 'Try adjusting your filters or search terms.'
: 'No audiobook requests have been made yet.'}
</p>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Request
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Requested
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Completed
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((request) => (
<tr
key={request.requestId}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
>
<td className="px-6 py-4">
<div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('title')}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{request.title}
</span>
{request.type === 'ebook' && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Ebook
</span>
)}
Request
<SortIcon field="title" currentSort={sortBy} currentOrder={sortOrder} />
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('user')}
>
<div className="flex items-center gap-2">
User
<SortIcon field="user" currentSort={sortBy} currentOrder={sortOrder} />
</div>
{request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{request.errorMessage}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-2">
Status
<SortIcon field="status" currentSort={sortBy} currentOrder={sortOrder} />
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('createdAt')}
>
<div className="flex items-center gap-2">
Requested
<SortIcon field="createdAt" currentSort={sortBy} currentOrder={sortOrder} />
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('completedAt')}
>
<div className="flex items-center gap-2">
Completed
<SortIcon field="completedAt" currentSort={sortBy} currentOrder={sortOrder} />
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((request) => (
<tr
key={request.requestId}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
>
<td className="px-6 py-4">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{request.title}
</span>
{request.type === 'ebook' && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Ebook
</span>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
</div>
{request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{request.errorMessage}
</div>
)}
</div>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{request.user}
</td>
<td className="px-6 py-4">{getStatusBadge(request.status)}</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{request.completedAt
? formatDistanceToNow(new Date(request.completedAt), {
addSuffix: true,
})
: '-'}
</td>
<td className="px-6 py-4">
<RequestActionsDropdown
request={{
requestId: request.requestId,
title: request.title,
author: request.author,
status: request.status,
type: request.type,
torrentUrl: request.torrentUrl,
}}
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
onFetchEbook={handleFetchEbook}
ebookSidecarEnabled={ebookSidecarEnabled}
isLoading={isDeleting || isFetchingEbook}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
{/* Results info */}
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{startIndex}</span> to{' '}
<span className="font-medium">{endIndex}</span> of{' '}
<span className="font-medium">{total}</span> requests
</div>
<div className="flex items-center gap-4">
{/* Page size selector */}
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700 dark:text-gray-300">Show:</label>
<select
value={pageSize}
onChange={(e) => updateParams({ pageSize: parseInt(e.target.value, 10) })}
className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
{/* Page navigation */}
<div className="flex items-center gap-1">
<button
onClick={() => updateParams({ page: 1 })}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="First page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => updateParams({ page: page - 1 })}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Previous page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Page numbers */}
<div className="flex items-center gap-1 mx-2">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => updateParams({ page: pageNum })}
className={`w-8 h-8 rounded-lg text-sm font-medium transition-colors ${
page === pageNum
? 'bg-blue-600 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{pageNum}
</button>
);
})}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{request.user}
</td>
<td className="px-6 py-4">
{getStatusBadge(request.status)}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{request.completedAt
? formatDistanceToNow(new Date(request.completedAt), {
addSuffix: true,
})
: '-'}
</td>
<td className="px-6 py-4">
<RequestActionsDropdown
request={{
requestId: request.requestId,
title: request.title,
author: request.author,
status: request.status,
type: request.type,
torrentUrl: request.torrentUrl,
}}
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
onFetchEbook={handleFetchEbook}
ebookSidecarEnabled={ebookSidecarEnabled}
isLoading={isDeleting || isFetchingEbook}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() => updateParams({ page: page + 1 })}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Next page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={() => updateParams({ page: totalPages })}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Last page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
</div>
</>
)}
{/* Confirm Dialog */}
<ConfirmDialog
+19 -17
View File
@@ -13,7 +13,7 @@ import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
import { useState, Suspense } from 'react';
interface PendingApprovalRequest {
id: string;
@@ -303,13 +303,7 @@ function AdminDashboardContent() {
}
);
const { data: requestsData, error: requestsError } = useSWR(
'/api/admin/requests/recent',
authenticatedFetcher,
{
refreshInterval: 10000,
}
);
// Note: RecentRequestsTable now fetches its own data with filtering/pagination
const { data: pendingApprovalData } = useSWR(
'/api/admin/requests/pending-approval',
@@ -327,8 +321,8 @@ function AdminDashboardContent() {
}
);
const isLoading = !metrics || !downloadsData || !requestsData;
const hasError = metricsError || downloadsError || requestsError;
const isLoading = !metrics || !downloadsData;
const hasError = metricsError || downloadsError;
if (hasError) {
return (
@@ -341,7 +335,6 @@ function AdminDashboardContent() {
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{metricsError?.message ||
downloadsError?.message ||
requestsError?.message ||
'Failed to load dashboard data'}
</p>
</div>
@@ -490,15 +483,24 @@ function AdminDashboardContent() {
<ActiveDownloadsTable downloads={downloadsData.downloads} />
</div>
{/* Recent Requests */}
{/* Request Management */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Recent Requests
Request Management
</h2>
<RecentRequestsTable
requests={requestsData.requests}
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
/>
<Suspense
fallback={
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
}
>
<RecentRequestsTable
ebookSidecarEnabled={settingsData?.ebook?.annasArchiveEnabled || settingsData?.ebook?.indexerSearchEnabled || false}
/>
</Suspense>
</div>
{/* Quick Actions */}
+156
View File
@@ -0,0 +1,156 @@
/**
* Component: Admin Requests API (Paginated)
* Documentation: documentation/admin-dashboard.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';
import { Prisma } from '@/generated/prisma';
const logger = RMABLogger.create('API.Admin.Requests');
const VALID_SORT_FIELDS = ['createdAt', 'completedAt', 'title', 'user', 'status'] as const;
const VALID_SORT_ORDERS = ['asc', 'desc'] as const;
const VALID_PAGE_SIZES = [10, 25, 50, 100] as const;
type SortField = (typeof VALID_SORT_FIELDS)[number];
type SortOrder = (typeof VALID_SORT_ORDERS)[number];
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { searchParams } = new URL(request.url);
// Parse query parameters
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
const pageSizeParam = parseInt(searchParams.get('pageSize') || '25', 10);
const pageSize = VALID_PAGE_SIZES.includes(pageSizeParam as (typeof VALID_PAGE_SIZES)[number])
? pageSizeParam
: 25;
const search = searchParams.get('search')?.trim() || '';
const status = searchParams.get('status') || '';
const userId = searchParams.get('userId') || '';
const sortByParam = searchParams.get('sortBy') || 'createdAt';
const sortBy: SortField = VALID_SORT_FIELDS.includes(sortByParam as SortField)
? (sortByParam as SortField)
: 'createdAt';
const sortOrderParam = searchParams.get('sortOrder') || 'desc';
const sortOrder: SortOrder = VALID_SORT_ORDERS.includes(sortOrderParam as SortOrder)
? (sortOrderParam as SortOrder)
: 'desc';
// Build where clause
const where: Prisma.RequestWhereInput = {
deletedAt: null,
};
// Filter by status
if (status && status !== 'all') {
where.status = status;
}
// Filter by user
if (userId) {
where.userId = userId;
}
// Search by title or author
if (search) {
where.audiobook = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ author: { contains: search, mode: 'insensitive' } },
],
};
}
// Build orderBy clause
let orderBy: Prisma.RequestOrderByWithRelationInput;
switch (sortBy) {
case 'title':
orderBy = { audiobook: { title: sortOrder } };
break;
case 'user':
orderBy = { user: { plexUsername: sortOrder } };
break;
case 'completedAt':
// Sort nulls last for completedAt
orderBy = { completedAt: { sort: sortOrder, nulls: 'last' } };
break;
case 'status':
orderBy = { status: sortOrder };
break;
case 'createdAt':
default:
orderBy = { createdAt: sortOrder };
break;
}
// Get total count for pagination
const total = await prisma.request.count({ where });
// Get paginated requests
const requests = await prisma.request.findMany({
where,
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
},
},
user: {
select: {
id: true,
plexUsername: true,
},
},
downloadHistory: {
where: {
selected: true,
},
select: {
torrentUrl: true,
},
take: 1,
},
},
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
});
// Format response
const formatted = requests.map((request) => ({
requestId: request.id,
title: request.audiobook.title,
author: request.audiobook.author,
status: request.status,
userId: request.user.id,
user: request.user.plexUsername,
createdAt: request.createdAt,
completedAt: request.completedAt,
errorMessage: request.errorMessage,
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
}));
return NextResponse.json({
requests: formatted,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
} catch (error) {
logger.error('Failed to fetch requests', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json({ error: 'Failed to fetch requests' }, { status: 500 });
}
});
});
}
@@ -1,13 +1,19 @@
/**
* Component: Admin Download Client Settings API
* Documentation: documentation/settings-pages.md
* Component: Admin Download Client Settings API (DEPRECATED)
* Documentation: documentation/phase3/download-clients.md
*
* DEPRECATED: This route is deprecated in favor of /api/admin/settings/download-clients
* which supports multiple download clients. This route is maintained for backward
* compatibility but updates are written to the new multi-client format.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getConfigService } from '@/lib/services/config.service';
import { getDownloadClientManager, invalidateDownloadClientManager, DownloadClientConfig } from '@/lib/services/download-client-manager.service';
import { PathMapper } from '@/lib/utils/path-mapper';
import { RMABLogger } from '@/lib/utils/logger';
import { randomUUID } from 'crypto';
const logger = RMABLogger.create('API.Admin.Settings.DownloadClient');
@@ -26,6 +32,8 @@ export async function PUT(request: NextRequest) {
localPath,
} = await request.json();
logger.warn('DEPRECATED: Using legacy single-client API. Please use /api/admin/settings/download-clients instead.');
// Validate type
if (type !== 'qbittorrent' && type !== 'sabnzbd') {
return NextResponse.json(
@@ -78,69 +86,51 @@ export async function PUT(request: NextRequest) {
}
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_client_type' },
update: { value: type },
create: { key: 'download_client_type', value: type },
});
// Get existing clients from new format
const config = await getConfigService();
const manager = getDownloadClientManager(config);
const existingClients = await manager.getAllClients();
await prisma.configuration.upsert({
where: { key: 'download_client_url' },
update: { value: url },
create: { key: 'download_client_url', value: url },
});
// Find existing client of same type to update, or create new
const existingIndex = existingClients.findIndex(c => c.type === type);
await prisma.configuration.upsert({
where: { key: 'download_client_username' },
update: { value: username },
create: { key: 'download_client_username', value: username },
});
const updatedClient: DownloadClientConfig = {
id: existingIndex >= 0 ? existingClients[existingIndex].id : randomUUID(),
type,
name: type === 'qbittorrent' ? 'qBittorrent' : 'SABnzbd',
enabled: true,
url,
username: username || undefined,
// Only update password if it's not the masked value
password: password.startsWith('••••') && existingIndex >= 0
? existingClients[existingIndex].password
: password,
disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined,
localPath: localPath || undefined,
category: existingIndex >= 0 ? existingClients[existingIndex].category : 'readmeabook',
};
// Only update password if it's not the masked value
if (!password.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'download_client_password' },
update: { value: password },
create: { key: 'download_client_password', value: password },
});
// Update or add client
let updatedClients: DownloadClientConfig[];
if (existingIndex >= 0) {
updatedClients = [...existingClients];
updatedClients[existingIndex] = updatedClient;
} else {
updatedClients = [...existingClients, updatedClient];
}
// Save SSL verification setting
await prisma.configuration.upsert({
where: { key: 'download_client_disable_ssl_verify' },
update: { value: disableSSLVerify ? 'true' : 'false' },
create: {
key: 'download_client_disable_ssl_verify',
value: disableSSLVerify ? 'true' : 'false',
},
});
// Save to new format
await config.setMany([
{ key: 'download_clients', value: JSON.stringify(updatedClients) },
]);
// Save remote path mapping configuration
await prisma.configuration.upsert({
where: { key: 'download_client_remote_path_mapping_enabled' },
update: { value: remotePathMappingEnabled ? 'true' : 'false' },
create: {
key: 'download_client_remote_path_mapping_enabled',
value: remotePathMappingEnabled ? 'true' : 'false',
},
});
logger.info('Download client settings updated via legacy API', { type, id: updatedClient.id });
await prisma.configuration.upsert({
where: { key: 'download_client_remote_path' },
update: { value: remotePath || '' },
create: { key: 'download_client_remote_path', value: remotePath || '' },
});
// Invalidate caches
invalidateDownloadClientManager();
await prisma.configuration.upsert({
where: { key: 'download_client_local_path' },
update: { value: localPath || '' },
create: { key: 'download_client_local_path', value: localPath || '' },
});
logger.info('Download client settings updated');
// Invalidate download client service singleton to force reload of credentials and URL
if (type === 'qbittorrent') {
const { invalidateQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
invalidateQBittorrentService();
@@ -152,6 +142,8 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
success: true,
message: 'Download client settings updated successfully',
deprecated: true,
warning: 'This API is deprecated. Please use /api/admin/settings/download-clients instead.',
});
} catch (error) {
logger.error('Failed to update download client settings', { error: error instanceof Error ? error.message : String(error) });
+40 -11
View File
@@ -81,17 +81,46 @@ export async function GET(request: NextRequest) {
url: configMap.get('prowlarr_url') || '',
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
},
downloadClient: {
type: configMap.get('download_client_type') || 'qbittorrent',
url: configMap.get('download_client_url') || '',
username: configMap.get('download_client_username') || '',
password: maskValue('password', configMap.get('download_client_password')),
disableSSLVerify: configMap.get('download_client_disable_ssl_verify') === 'true',
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true',
remotePath: configMap.get('download_client_remote_path') || '',
localPath: configMap.get('download_client_local_path') || '',
},
// downloadClient is populated from multi-client format for backward compatibility
// The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients
downloadClient: (() => {
// Try to read from new multi-client format first
const downloadClientsJson = configMap.get('download_clients');
if (downloadClientsJson) {
try {
const clients = JSON.parse(downloadClientsJson);
// Return the first enabled client for backward compatibility
const firstClient = clients.find((c: any) => c.enabled) || clients[0];
if (firstClient) {
return {
type: firstClient.type || 'qbittorrent',
url: firstClient.url || '',
username: firstClient.username || '',
password: maskValue('password', firstClient.password),
disableSSLVerify: firstClient.disableSSLVerify === true,
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
remotePathMappingEnabled: firstClient.remotePathMappingEnabled === true,
remotePath: firstClient.remotePath || '',
localPath: firstClient.localPath || '',
};
}
} catch {
// Fall through to legacy format
}
}
// Fall back to legacy flat keys
return {
type: configMap.get('download_client_type') || 'qbittorrent',
url: configMap.get('download_client_url') || '',
username: configMap.get('download_client_username') || '',
password: maskValue('password', configMap.get('download_client_password')),
disableSSLVerify: configMap.get('download_client_disable_ssl_verify') === 'true',
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true',
remotePath: configMap.get('download_client_remote_path') || '',
localPath: configMap.get('download_client_local_path') || '',
};
})(),
paths: {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
@@ -35,12 +35,15 @@ const RequestWithTorrentSchema = z.object({
seeders: z.number().optional(), // Optional for NZB/Usenet results
leechers: z.number().optional(), // Optional for NZB/Usenet results
indexer: z.string(),
indexerId: z.number().optional(), // Prowlarr indexer ID
downloadUrl: z.string(),
infoUrl: z.string().optional(), // Link to indexer's info page
publishDate: z.string().transform((str) => new Date(str)),
infoHash: z.string().optional(),
format: z.enum(['M4B', 'M4A', 'MP3', 'OTHER']).optional(),
bitrate: z.string().optional(),
hasChapters: z.boolean().optional(),
protocol: z.enum(['torrent', 'usenet']).optional(), // Protocol from Prowlarr API
}),
});
-14
View File
@@ -418,20 +418,6 @@ export async function POST(request: NextRequest) {
create: { key: 'download_clients', value: JSON.stringify(downloadClientsArray) },
});
// Legacy: Keep old keys for backward compatibility with migration
// (Will be cleaned up by migration on first access)
await prisma.configuration.upsert({
where: { key: 'download_client_remote_path' },
update: { value: downloadClient.remotePath || '' },
create: { key: 'download_client_remote_path', value: downloadClient.remotePath || '' },
});
await prisma.configuration.upsert({
where: { key: 'download_client_local_path' },
update: { value: downloadClient.localPath || '' },
create: { key: 'download_client_local_path', value: downloadClient.localPath || '' },
});
// Path configuration
await prisma.configuration.upsert({
where: { key: 'download_dir' },
@@ -8,8 +8,9 @@ import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queu
import { prisma } from '../db';
import { getQBittorrentService } from '../integrations/qbittorrent.service';
import { RMABLogger } from '../utils/logger';
import { PathMapper } from '../utils/path-mapper';
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
@@ -130,20 +131,23 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
throw new Error('Download path not available from download client');
}
// Load path mapping configuration
// Get path mapping configuration from the specific download client
const configService = getConfigService();
const pathMappingConfig = await configService.getMany([
'download_client_remote_path_mapping_enabled',
'download_client_remote_path',
'download_client_local_path',
]);
const manager = getDownloadClientManager(configService);
const protocol = downloadClient === 'sabnzbd' ? 'usenet' : 'torrent';
const clientConfig = await manager.getClientForProtocol(protocol);
// Build path mapping config from client settings
const pathMappingConfig: PathMappingConfig = clientConfig && clientConfig.remotePathMappingEnabled
? {
enabled: true,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
}
: { enabled: false, remotePath: '', localPath: '' };
// Apply remote-to-local path transformation if enabled
const organizePath = PathMapper.transform(downloadPath, {
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
remotePath: pathMappingConfig.download_client_remote_path || '',
localPath: pathMappingConfig.download_client_local_path || '',
});
const organizePath = PathMapper.transform(downloadPath, pathMappingConfig);
logger.info(`Download completed`, {
downloadClient,
@@ -9,7 +9,8 @@ import { prisma } from '../db';
import { RMABLogger } from '../utils/logger';
import { getJobQueueService } from '../services/job-queue.service';
import { getConfigService } from '../services/config.service';
import { PathMapper } from '../utils/path-mapper';
import { getDownloadClientManager } from '../services/download-client-manager.service';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
export interface RetryFailedImportsPayload {
jobId?: string;
@@ -23,18 +24,23 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
logger.info('Starting retry job for requests awaiting import...');
try {
// Load path mapping configuration once
// Initialize config and download client manager
const configService = getConfigService();
const pathMappingConfig = await configService.getMany([
'download_client_remote_path_mapping_enabled',
'download_client_remote_path',
'download_client_local_path',
]);
const manager = getDownloadClientManager(configService);
const mappingConfig = {
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
remotePath: pathMappingConfig.download_client_remote_path || '',
localPath: pathMappingConfig.download_client_local_path || '',
// 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 clientConfig = await manager.getClientForProtocol(protocol);
if (clientConfig && clientConfig.remotePathMappingEnabled) {
return {
enabled: true,
remotePath: clientConfig.remotePath || '',
localPath: clientConfig.localPath || '',
};
}
return { enabled: false, remotePath: '', localPath: '' };
};
// Find all active audiobook requests in awaiting_import status
@@ -85,6 +91,10 @@ 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 {
+167 -1
View File
@@ -44,7 +44,7 @@ describe('Admin requests routes', () => {
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
});
it('returns recent requests', async () => {
it('returns recent requests (legacy endpoint)', async () => {
prismaMock.request.findMany.mockResolvedValueOnce([
{
id: 'req-1',
@@ -66,6 +66,172 @@ describe('Admin requests routes', () => {
expect(payload.requests[0].torrentUrl).toBe('http://torrent');
});
it('returns paginated requests with default params', async () => {
prismaMock.request.count.mockResolvedValueOnce(1);
prismaMock.request.findMany.mockResolvedValueOnce([
{
id: 'req-1',
status: 'pending',
createdAt: new Date(),
completedAt: null,
errorMessage: null,
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
user: { id: 'u-1', plexUsername: 'user' },
downloadHistory: [{ torrentUrl: 'http://torrent' }],
},
]);
const mockRequest = {
url: 'http://localhost/api/admin/requests',
};
const { GET } = await import('@/app/api/admin/requests/route');
const response = await GET(mockRequest as any);
const payload = await response.json();
expect(payload.requests).toHaveLength(1);
expect(payload.total).toBe(1);
expect(payload.page).toBe(1);
expect(payload.pageSize).toBe(25);
expect(payload.totalPages).toBe(1);
expect(payload.requests[0].userId).toBe('u-1');
});
it('filters requests by status', async () => {
prismaMock.request.count.mockResolvedValueOnce(1);
prismaMock.request.findMany.mockResolvedValueOnce([
{
id: 'req-1',
status: 'failed',
createdAt: new Date(),
completedAt: null,
errorMessage: 'Search failed',
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
user: { id: 'u-1', plexUsername: 'user' },
downloadHistory: [],
},
]);
const mockRequest = {
url: 'http://localhost/api/admin/requests?status=failed',
};
const { GET } = await import('@/app/api/admin/requests/route');
const response = await GET(mockRequest as any);
const payload = await response.json();
expect(payload.requests).toHaveLength(1);
expect(payload.requests[0].status).toBe('failed');
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: 'failed',
}),
})
);
});
it('filters requests by userId', async () => {
prismaMock.request.count.mockResolvedValueOnce(1);
prismaMock.request.findMany.mockResolvedValueOnce([
{
id: 'req-1',
status: 'pending',
createdAt: new Date(),
completedAt: null,
errorMessage: null,
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
user: { id: 'user-123', plexUsername: 'specificuser' },
downloadHistory: [],
},
]);
const mockRequest = {
url: 'http://localhost/api/admin/requests?userId=user-123',
};
const { GET } = await import('@/app/api/admin/requests/route');
const response = await GET(mockRequest as any);
const payload = await response.json();
expect(payload.requests).toHaveLength(1);
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: 'user-123',
}),
})
);
});
it('searches requests by title/author', async () => {
prismaMock.request.count.mockResolvedValueOnce(1);
prismaMock.request.findMany.mockResolvedValueOnce([
{
id: 'req-1',
status: 'pending',
createdAt: new Date(),
completedAt: null,
errorMessage: null,
audiobook: { id: 'ab-1', title: 'Harry Potter', author: 'J.K. Rowling' },
user: { id: 'u-1', plexUsername: 'user' },
downloadHistory: [],
},
]);
const mockRequest = {
url: 'http://localhost/api/admin/requests?search=Harry',
};
const { GET } = await import('@/app/api/admin/requests/route');
const response = await GET(mockRequest as any);
const payload = await response.json();
expect(payload.requests).toHaveLength(1);
expect(payload.requests[0].title).toBe('Harry Potter');
});
it('paginates requests correctly', async () => {
prismaMock.request.count.mockResolvedValueOnce(100);
prismaMock.request.findMany.mockResolvedValueOnce([]);
const mockRequest = {
url: 'http://localhost/api/admin/requests?page=3&pageSize=10',
};
const { GET } = await import('@/app/api/admin/requests/route');
const response = await GET(mockRequest as any);
const payload = await response.json();
expect(payload.page).toBe(3);
expect(payload.pageSize).toBe(10);
expect(payload.totalPages).toBe(10);
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 20, // (page - 1) * pageSize = 2 * 10
take: 10,
})
);
});
it('sorts requests by different fields', async () => {
prismaMock.request.count.mockResolvedValueOnce(1);
prismaMock.request.findMany.mockResolvedValueOnce([]);
const mockRequest = {
url: 'http://localhost/api/admin/requests?sortBy=title&sortOrder=asc',
};
const { GET } = await import('@/app/api/admin/requests/route');
await GET(mockRequest as any);
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { audiobook: { title: 'asc' } },
})
);
});
it('soft deletes a request via delete service', async () => {
deleteRequestMock.mockResolvedValueOnce({
success: true,
@@ -29,6 +29,11 @@ const pathMapperMock = vi.hoisted(() => ({
}));
const invalidateQbMock = vi.hoisted(() => vi.fn());
const invalidateSabMock = vi.hoisted(() => vi.fn());
const invalidateDownloadClientManagerMock = vi.hoisted(() => vi.fn());
const downloadClientManagerMock = vi.hoisted(() => ({
getAllClients: vi.fn(),
testConnection: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
@@ -67,12 +72,20 @@ vi.mock('@/lib/integrations/sabnzbd.service', () => ({
invalidateSABnzbdService: invalidateSabMock,
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
invalidateDownloadClientManager: invalidateDownloadClientManagerMock,
}));
describe('Admin settings core routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
// Reset download client manager mocks with default values
downloadClientManagerMock.getAllClients.mockResolvedValue([]);
downloadClientManagerMock.testConnection.mockResolvedValue({ success: true, message: 'Connected' });
});
it('returns settings payload', async () => {
@@ -11,7 +11,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
const mutateMock = vi.hoisted(() => vi.fn());
const useSWRMock = vi.hoisted(() => vi.fn());
const toastMock = vi.hoisted(() => ({
success: vi.fn(),
error: vi.fn(),
@@ -19,18 +21,61 @@ const toastMock = vi.hoisted(() => ({
warning: vi.fn(),
}));
// Mock next/navigation
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
};
const mockSearchParams = new URLSearchParams();
vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
usePathname: () => '/admin',
useSearchParams: () => mockSearchParams,
}));
vi.mock('swr', () => ({
default: useSWRMock,
mutate: mutateMock,
}));
vi.mock('@/lib/utils/api', () => ({
fetchWithAuth: fetchWithAuthMock,
authenticatedFetcher: authenticatedFetcherMock,
}));
vi.mock('@/components/ui/Toast', () => ({
useToast: () => toastMock,
}));
const mockRequestsData = {
requests: [
{
requestId: 'req-1',
title: 'Test Audiobook',
author: 'Test Author',
status: 'pending',
userId: 'user-1',
user: 'TestUser',
createdAt: new Date('2024-01-01T00:00:00Z'),
completedAt: null,
errorMessage: null,
},
],
total: 1,
page: 1,
pageSize: 25,
totalPages: 1,
};
const mockUsersData = {
users: [
{ id: 'user-1', plexUsername: 'TestUser' },
{ id: 'user-2', plexUsername: 'OtherUser' },
],
};
let RecentRequestsTable: typeof import('@/app/admin/components/RecentRequestsTable').RecentRequestsTable;
describe('RecentRequestsTable', () => {
@@ -38,10 +83,22 @@ describe('RecentRequestsTable', () => {
vi.resetModules();
fetchWithAuthMock.mockReset();
mutateMock.mockReset();
mockRouter.push.mockReset();
toastMock.success.mockReset();
toastMock.error.mockReset();
toastMock.warning.mockReset();
// Default SWR mock - returns requests and users data
useSWRMock.mockImplementation((url: string) => {
if (url.includes('/api/admin/requests')) {
return { data: mockRequestsData, error: null, isLoading: false };
}
if (url === '/api/admin/users') {
return { data: mockUsersData, error: null, isLoading: false };
}
return { data: null, error: null, isLoading: false };
});
vi.doMock(path.resolve('src/app/admin/components/RequestActionsDropdown.tsx'), () => ({
RequestActionsDropdown: ({
request,
@@ -84,9 +141,57 @@ describe('RecentRequestsTable', () => {
});
it('shows empty state when there are no requests', () => {
render(<RecentRequestsTable requests={[]} />);
useSWRMock.mockImplementation((url: string) => {
if (url.includes('/api/admin/requests')) {
return {
data: { requests: [], total: 0, page: 1, pageSize: 25, totalPages: 0 },
error: null,
isLoading: false,
};
}
if (url === '/api/admin/users') {
return { data: mockUsersData, error: null, isLoading: false };
}
return { data: null, error: null, isLoading: false };
});
expect(screen.getByText('No Recent Requests')).toBeInTheDocument();
render(<RecentRequestsTable />);
expect(screen.getByText('No Requests')).toBeInTheDocument();
});
it('shows loading state while fetching', () => {
useSWRMock.mockImplementation(() => ({
data: null,
error: null,
isLoading: true,
}));
const { container } = render(<RecentRequestsTable />);
// Should show loading spinner (check for animate-spin class)
expect(container.querySelector('.animate-spin')).toBeInTheDocument();
});
it('renders requests table with data', () => {
const { container } = render(<RecentRequestsTable />);
expect(screen.getByText('Test Audiobook')).toBeInTheDocument();
expect(screen.getByText('Test Author')).toBeInTheDocument();
// TestUser appears in both dropdown and table, check for table cell content
expect(screen.getByRole('cell', { name: 'TestUser' })).toBeInTheDocument();
// Pending status badge (span with specific class)
const statusBadge = container.querySelector('span.inline-flex');
expect(statusBadge).toHaveTextContent('Pending');
});
it('renders filter controls', () => {
render(<RecentRequestsTable />);
expect(screen.getByPlaceholderText('Search by title or author...')).toBeInTheDocument();
// Check for status and user dropdowns via their options
expect(screen.getByRole('option', { name: 'All Statuses' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'All Users' })).toBeInTheDocument();
});
it('deletes a request and refreshes caches', async () => {
@@ -95,22 +200,7 @@ describe('RecentRequestsTable', () => {
json: async () => ({ success: true }),
});
render(
<RecentRequestsTable
requests={[
{
requestId: 'req-1',
title: 'Delete Me',
author: 'Author',
status: 'pending',
user: 'User',
createdAt: new Date('2024-01-01T00:00:00Z'),
completedAt: null,
errorMessage: null,
},
]}
/>
);
render(<RecentRequestsTable />);
fireEvent.click(screen.getByRole('button', { name: 'Delete Trigger' }));
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
@@ -122,16 +212,10 @@ describe('RecentRequestsTable', () => {
});
});
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/recent');
// Should mutate the current API URL and metrics
expect(mutateMock).toHaveBeenCalledWith(expect.stringContaining('/api/admin/requests'));
expect(mutateMock).toHaveBeenCalledWith('/api/admin/metrics');
const predicateCall = mutateMock.mock.calls.find(
(call) => typeof call[0] === 'function'
);
expect(predicateCall).toBeTruthy();
const predicate = predicateCall?.[0] as (key: unknown) => boolean;
expect(predicate('/api/audiobooks?query=test')).toBe(true);
expect(predicate('/api/other')).toBe(false);
expect(toastMock.success).toHaveBeenCalledWith('Request deleted successfully');
});
it('warns when ebook fetch fails', async () => {
@@ -140,34 +224,76 @@ describe('RecentRequestsTable', () => {
json: async () => ({ success: false, message: 'No ebook available' }),
});
render(
<RecentRequestsTable
requests={[
{
requestId: 'req-2',
title: 'Needs Ebook',
author: 'Author',
status: 'downloaded',
user: 'User',
createdAt: new Date('2024-01-01T00:00:00Z'),
completedAt: null,
errorMessage: null,
},
]}
ebookSidecarEnabled
/>
);
render(<RecentRequestsTable ebookSidecarEnabled />);
fireEvent.click(screen.getByRole('button', { name: 'Fetch Ebook Trigger' }));
await waitFor(() => {
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-2/fetch-ebook', {
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-1/fetch-ebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(toastMock.warning).toHaveBeenCalledWith(
'E-book fetch failed: No ebook available'
);
expect(toastMock.warning).toHaveBeenCalledWith('E-book fetch failed: No ebook available');
});
});
it('triggers manual search', async () => {
fetchWithAuthMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
render(<RecentRequestsTable />);
fireEvent.click(screen.getByRole('button', { name: 'Manual Search Trigger' }));
await waitFor(() => {
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-1/manual-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
expect(toastMock.success).toHaveBeenCalledWith('Manual search triggered');
});
});
it('cancels a request', async () => {
fetchWithAuthMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
render(<RecentRequestsTable />);
fireEvent.click(screen.getByRole('button', { name: 'Cancel Trigger' }));
await waitFor(() => {
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'cancel' }),
});
expect(toastMock.success).toHaveBeenCalledWith('Request cancelled');
});
});
it('shows pagination info', () => {
const { container } = render(<RecentRequestsTable />);
// Check pagination text container exists with expected content
const paginationText = container.querySelector('.text-gray-700');
expect(paginationText).toHaveTextContent('Showing');
expect(paginationText).toHaveTextContent('requests');
});
it('shows error state when fetch fails', () => {
useSWRMock.mockImplementation(() => ({
data: null,
error: new Error('Network error'),
isLoading: false,
}));
render(<RecentRequestsTable />);
expect(screen.getByText('Failed to load requests. Please try again.')).toBeInTheDocument();
});
});
@@ -19,6 +19,9 @@ const sabMock = vi.hoisted(() => ({
const configMock = vi.hoisted(() => ({
getMany: vi.fn(),
}));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
@@ -40,6 +43,10 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
describe('processMonitorDownload', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -57,10 +64,14 @@ describe('processMonitorDownload', () => {
speed: 0,
eta: 0,
});
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'true',
download_client_remote_path: '/remote/done',
download_client_local_path: '/downloads',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: true,
remotePath: '/remote/done',
localPath: '/downloads',
});
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
@@ -161,10 +172,12 @@ describe('processMonitorDownload', () => {
timeLeft: 0,
downloadPath: '/usenet/complete/Book',
});
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.update.mockResolvedValue({});
prismaMock.downloadHistory.update.mockResolvedValue({});
@@ -11,9 +11,11 @@ import { createJobQueueMock } from '../helpers/job-queue';
const prismaMock = createPrismaMock();
const jobQueueMock = createJobQueueMock();
const configMock = vi.hoisted(() => ({
getMany: vi.fn(),
get: vi.fn(),
}));
const downloadClientManagerMock = vi.hoisted(() => ({
getClientForProtocol: vi.fn(),
}));
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
@@ -29,6 +31,10 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
vi.mock('@/lib/services/download-client-manager.service', () => ({
getDownloadClientManager: () => downloadClientManagerMock,
}));
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
getQBittorrentService: () => qbtMock,
}));
@@ -43,17 +49,19 @@ describe('processRetryFailedImports', () => {
});
it('queues organize jobs using download client paths', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-1',
audiobook: { id: 'a1', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-1', torrentName: 'Book' }],
downloadHistory: [{ torrentHash: 'hash-1', torrentName: 'Book', downloadClient: 'qbittorrent' }],
},
]);
@@ -74,11 +82,6 @@ describe('processRetryFailedImports', () => {
});
it('returns early when no requests await import', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([]);
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
@@ -90,11 +93,6 @@ describe('processRetryFailedImports', () => {
});
it('skips requests missing download history', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-2',
@@ -111,10 +109,14 @@ describe('processRetryFailedImports', () => {
});
it('falls back to configured download dir when qBittorrent lookup fails', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'true',
download_client_remote_path: '/remote',
download_client_local_path: '/downloads',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: true,
remotePath: '/remote',
localPath: '/downloads',
});
configMock.get.mockResolvedValue('/remote');
@@ -122,7 +124,7 @@ describe('processRetryFailedImports', () => {
{
id: 'req-3',
audiobook: { id: 'a3', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-3', torrentName: 'Book' }],
downloadHistory: [{ torrentHash: 'hash-3', torrentName: 'Book', downloadClient: 'qbittorrent' }],
},
]);
@@ -140,16 +142,20 @@ describe('processRetryFailedImports', () => {
});
it('uses SABnzbd download path when available', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'true',
download_client_remote_path: '/remote/nzb',
download_client_local_path: '/downloads',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
remotePathMappingEnabled: true,
remotePath: '/remote/nzb',
localPath: '/downloads',
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-4',
audiobook: { id: 'a4', title: 'Book' },
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'Book' }],
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'Book', downloadClient: 'sabnzbd' }],
},
]);
@@ -167,17 +173,19 @@ describe('processRetryFailedImports', () => {
});
it('skips SABnzbd retries when download dir is missing', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
remotePathMappingEnabled: false,
});
configMock.get.mockResolvedValue(null);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-5',
audiobook: { id: 'a5', title: 'Book' },
downloadHistory: [{ nzbId: 'nzb-2', torrentName: 'Book' }],
downloadHistory: [{ nzbId: 'nzb-2', torrentName: 'Book', downloadClient: 'sabnzbd' }],
},
]);
@@ -191,16 +199,18 @@ describe('processRetryFailedImports', () => {
});
it('skips requests with no client identifiers or names', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-6',
audiobook: { id: 'a6', title: 'Book' },
downloadHistory: [{}],
downloadHistory: [{ downloadClient: 'qbittorrent' }],
},
]);
@@ -212,16 +222,18 @@ describe('processRetryFailedImports', () => {
});
it('tracks skipped requests when organize job fails', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-7',
audiobook: { id: 'a7', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book' }],
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book', downloadClient: 'qbittorrent' }],
},
]);
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
@@ -235,16 +247,18 @@ describe('processRetryFailedImports', () => {
});
it('skips qBittorrent fallbacks when torrent name is missing', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-8',
audiobook: { id: 'a8', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-8' }],
downloadHistory: [{ torrentHash: 'hash-8', downloadClient: 'qbittorrent' }],
},
]);
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
@@ -258,17 +272,19 @@ describe('processRetryFailedImports', () => {
});
it('skips qBittorrent fallbacks when download_dir is not configured', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
configMock.get.mockResolvedValue(null);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-9',
audiobook: { id: 'a9', title: 'Book' },
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book' }],
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book', downloadClient: 'qbittorrent' }],
},
]);
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
@@ -281,16 +297,18 @@ describe('processRetryFailedImports', () => {
});
it('skips SABnzbd retries when the client throws', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-2',
type: 'sabnzbd',
name: 'SABnzbd',
enabled: true,
remotePathMappingEnabled: false,
});
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-10',
audiobook: { id: 'a10', title: 'Book' },
downloadHistory: [{ nzbId: 'nzb-10', torrentName: 'Book' }],
downloadHistory: [{ nzbId: 'nzb-10', torrentName: 'Book', downloadClient: 'sabnzbd' }],
},
]);
@@ -304,17 +322,19 @@ describe('processRetryFailedImports', () => {
});
it('skips requests without download_dir when no client identifiers exist', async () => {
configMock.getMany.mockResolvedValue({
download_client_remote_path_mapping_enabled: 'false',
download_client_remote_path: '',
download_client_local_path: '',
downloadClientManagerMock.getClientForProtocol.mockResolvedValue({
id: 'client-1',
type: 'qbittorrent',
name: 'qBittorrent',
enabled: true,
remotePathMappingEnabled: false,
});
configMock.get.mockResolvedValue(null);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-11',
audiobook: { id: 'a11', title: 'Book' },
downloadHistory: [{ torrentName: 'Book' }],
downloadHistory: [{ torrentName: 'Book', downloadClient: 'qbittorrent' }],
},
]);