diff --git a/documentation/admin-dashboard.md b/documentation/admin-dashboard.md
index 034613b..c67326f 100644
--- a/documentation/admin-dashboard.md
+++ b/documentation/admin-dashboard.md
@@ -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)
diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx
index f165669..1735eba 100644
--- a/src/app/admin/components/RecentRequestsTable.tsx
+++ b/src/app/admin/components/RecentRequestsTable.tsx
@@ -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 {
@@ -18,6 +20,7 @@ interface RecentRequest {
title: string;
author: string;
status: string;
+ userId: string;
user: string;
createdAt: Date;
completedAt: Date | null;
@@ -25,14 +28,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 = {
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',
@@ -44,6 +83,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';
@@ -51,6 +91,7 @@ function getStatusBadge(status: string) {
const labels: Record = {
awaiting_search: 'Awaiting Search',
awaiting_import: 'Awaiting Import',
+ awaiting_approval: 'Awaiting Approval',
};
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
@@ -64,7 +105,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 (
+
+
+
+ );
+ }
+
+ return currentOrder === 'asc' ? (
+
+
+
+ ) : (
+
+
+
+ );
+}
+
+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;
@@ -72,8 +151,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(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) => {
+ 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);
@@ -97,21 +242,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'}`);
@@ -139,9 +277,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'}`);
@@ -163,9 +300,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'}`);
@@ -182,16 +318,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);
@@ -201,16 +337,104 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
}
};
- if (requests.length === 0) {
+ // Render loading state
+ if (isLoading && !data) {
return (
-
+ );
+ }
+
+ // Render error state
+ if (error) {
+ return (
+
+
+ Failed to load requests. Please try again.
+
+
+ );
+ }
+
+ 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 (
+
+ {/* Filter Bar */}
+
+
+ {/* Search Input */}
+
+
+
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"
+ />
+
+
+ {/* Status Filter */}
+
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.label}
+
+ ))}
+
+
+ {/* User Filter */}
+
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]"
+ >
+ All Users
+ {usersData?.users.map((user) => (
+
+ {user.plexUsername}
+
+ ))}
+
+
+ {/* Clear Filters Button */}
+ {hasActiveFilters && (
+
+
+
+
+ Clear
+
+ )}
+
+
+
+ {/* Table */}
+ {requests.length === 0 ? (
+
+
- No Recent Requests
+ {hasActiveFilters ? 'No Matching Requests' : 'No Requests'}
- No audiobook requests have been made yet.
+ {hasActiveFilters
+ ? 'Try adjusting your filters or search terms.'
+ : 'No audiobook requests have been made yet.'}
-
- );
- }
-
- return (
-
-
-
-
-
-
- Audiobook
-
-
- User
-
-
- Status
-
-
- Requested
-
-
- Completed
-
-
- Actions
-
-
-
-
- {requests.map((request) => (
-
-
-
-
- {request.title}
+ ) : (
+ <>
+
+
+
+
+ handleSort('title')}
+ >
+
+ Audiobook
+
-
- {request.author}
+
+ handleSort('user')}
+ >
+
+ User
+
- {request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
-
- {request.errorMessage}
+
+ handleSort('status')}
+ >
+
+ Status
+
+
+
+ handleSort('createdAt')}
+ >
+
+ Requested
+
+
+
+ handleSort('completedAt')}
+ >
+
+ Completed
+
+
+
+
+ Actions
+
+
+
+
+ {requests.map((request) => (
+
+
+
+
+ {request.title}
+
+
+ {request.author}
+
+ {request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
+
+ {request.errorMessage}
+
+ )}
- )}
+
+
+ {request.user}
+
+ {getStatusBadge(request.status)}
+
+ {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
+
+
+ {request.completedAt
+ ? formatDistanceToNow(new Date(request.completedAt), {
+ addSuffix: true,
+ })
+ : '-'}
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+
+
+ {/* Results info */}
+
+ Showing {startIndex} to{' '}
+ {endIndex} of{' '}
+ {total} requests
+
+
+
+ {/* Page size selector */}
+
+ Show:
+ 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) => (
+
+ {size}
+
+ ))}
+
+
+
+ {/* Page navigation */}
+
+
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"
+ >
+
+
+
+
+
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"
+ >
+
+
+
+
+
+ {/* Page numbers */}
+
+ {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 (
+ 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}
+
+ );
+ })}
-
-
- {request.user}
-
- {getStatusBadge(request.status)}
-
- {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
-
-
- {request.completedAt
- ? formatDistanceToNow(new Date(request.completedAt), {
- addSuffix: true,
- })
- : '-'}
-
-
-
-
-
- ))}
-
-
-
+
+
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"
+ >
+
+
+
+
+
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"
+ >
+
+
+
+
+
+
+
+
+ >
+ )}
{/* Confirm Dialog */}
{metricsError?.message ||
downloadsError?.message ||
- requestsError?.message ||
'Failed to load dashboard data'}
@@ -490,15 +483,24 @@ function AdminDashboardContent() {
- {/* Recent Requests */}
+ {/* Request Management */}
- Recent Requests
+ Request Management
-
+
+
+
+ }
+ >
+
+
{/* Quick Actions */}
diff --git a/src/app/api/admin/requests/route.ts b/src/app/api/admin/requests/route.ts
new file mode 100644
index 0000000..8c50d00
--- /dev/null
+++ b/src/app/api/admin/requests/route.ts
@@ -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 });
+ }
+ });
+ });
+}
diff --git a/src/app/api/admin/settings/download-client/route.ts b/src/app/api/admin/settings/download-client/route.ts
index 673b887..65b1289 100644
--- a/src/app/api/admin/settings/download-client/route.ts
+++ b/src/app/api/admin/settings/download-client/route.ts
@@ -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) });
diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts
index 059b4c7..4895165 100644
--- a/src/app/api/admin/settings/route.ts
+++ b/src/app/api/admin/settings/route.ts
@@ -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',
diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts
index b2cdab7..8d99adb 100644
--- a/src/app/api/audiobooks/request-with-torrent/route.ts
+++ b/src/app/api/audiobooks/request-with-torrent/route.ts
@@ -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
}),
});
diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts
index 0e74f57..9ebd744 100644
--- a/src/app/api/setup/complete/route.ts
+++ b/src/app/api/setup/complete/route.ts
@@ -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' },
diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts
index 4cd509e..bc8133a 100644
--- a/src/lib/processors/monitor-download.processor.ts
+++ b/src/lib/processors/monitor-download.processor.ts
@@ -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,
diff --git a/src/lib/processors/retry-failed-imports.processor.ts b/src/lib/processors/retry-failed-imports.processor.ts
index ac1d41c..17258c2 100644
--- a/src/lib/processors/retry-failed-imports.processor.ts
+++ b/src/lib/processors/retry-failed-imports.processor.ts
@@ -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 => {
+ 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 requests in awaiting_import status
@@ -83,6 +89,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 {
diff --git a/tests/api/admin-requests.routes.test.ts b/tests/api/admin-requests.routes.test.ts
index d8c5614..2ec1586 100644
--- a/tests/api/admin-requests.routes.test.ts
+++ b/tests/api/admin-requests.routes.test.ts
@@ -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,
diff --git a/tests/api/admin-settings-core.routes.test.ts b/tests/api/admin-settings-core.routes.test.ts
index cfe62eb..df74c54 100644
--- a/tests/api/admin-settings-core.routes.test.ts
+++ b/tests/api/admin-settings-core.routes.test.ts
@@ -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 () => {
diff --git a/tests/app/admin/components/RecentRequestsTable.test.tsx b/tests/app/admin/components/RecentRequestsTable.test.tsx
index 967a311..6ffa65f 100644
--- a/tests/app/admin/components/RecentRequestsTable.test.tsx
+++ b/tests/app/admin/components/RecentRequestsTable.test.tsx
@@ -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( );
+ 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( );
+
+ expect(screen.getByText('No Requests')).toBeInTheDocument();
+ });
+
+ it('shows loading state while fetching', () => {
+ useSWRMock.mockImplementation(() => ({
+ data: null,
+ error: null,
+ isLoading: true,
+ }));
+
+ const { container } = render( );
+
+ // Should show loading spinner (check for animate-spin class)
+ expect(container.querySelector('.animate-spin')).toBeInTheDocument();
+ });
+
+ it('renders requests table with data', () => {
+ const { container } = render( );
+
+ 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( );
+
+ 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(
-
- );
+ render( );
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(
-
- );
+ render( );
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( );
+
+ 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( );
+
+ 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( );
+
+ // 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( );
+
+ expect(screen.getByText('Failed to load requests. Please try again.')).toBeInTheDocument();
+ });
});
diff --git a/tests/processors/monitor-download.processor.test.ts b/tests/processors/monitor-download.processor.test.ts
index fd4ba60..78087e3 100644
--- a/tests/processors/monitor-download.processor.test.ts
+++ b/tests/processors/monitor-download.processor.test.ts
@@ -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({});
diff --git a/tests/processors/retry-failed-imports.processor.test.ts b/tests/processors/retry-failed-imports.processor.test.ts
index 50bd70d..3fe8d83 100644
--- a/tests/processors/retry-failed-imports.processor.test.ts
+++ b/tests/processors/retry-failed-imports.processor.test.ts
@@ -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' }],
},
]);