mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge branch 'main' into ebook-piecewise
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user