Add e-book fetch API and UI integration for requests

Introduces an API endpoint to trigger e-book downloads for completed requests, with admin UI integration in RecentRequestsTable and RequestActionsDropdown. Updates the admin dashboard to detect e-book sidecar feature availability from settings. Enhances torrent search result handling with info URLs, improves ranking algorithm normalization, and refines interactive search to show all results without threshold filtering. Also allows nullable ratings in request schemas.
This commit is contained in:
kikootwo
2026-01-08 02:09:57 -05:00
parent 95c25ff73a
commit 722a78ac33
10 changed files with 342 additions and 57 deletions
@@ -11,6 +11,7 @@ import { ConfirmDialog } from './ConfirmDialog';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { mutate } from 'swr';
import { fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast';
interface RecentRequest {
requestId: string;
@@ -26,6 +27,7 @@ interface RecentRequest {
interface RecentRequestsTableProps {
requests: RecentRequest[];
ebookSidecarEnabled?: boolean;
}
function getStatusBadge(status: string) {
@@ -62,13 +64,15 @@ function getStatusBadge(status: string) {
);
}
export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: RecentRequestsTableProps) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<{
id: string;
title: string;
} | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isFetchingEbook, setIsFetchingEbook] = useState(false);
const toast = useToast();
const handleDeleteClick = (requestId: string, title: string) => {
setSelectedRequest({ id: requestId, title });
@@ -110,11 +114,7 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
setSelectedRequest(null);
} catch (error) {
console.error('[Admin] Failed to delete request:', error);
alert(
`Failed to delete request: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
toast.error(`Failed to delete request: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsDeleting(false);
}
@@ -144,11 +144,7 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
await mutate('/api/admin/requests/recent');
} catch (error) {
console.error('[Admin] Failed to trigger manual search:', error);
alert(
`Failed to trigger manual search: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
toast.error(`Failed to trigger manual search: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
@@ -172,11 +168,36 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
await mutate('/api/admin/requests/recent');
} catch (error) {
console.error('[Admin] Failed to cancel request:', error);
alert(
`Failed to cancel request: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
toast.error(`Failed to cancel request: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleFetchEbook = async (requestId: string) => {
setIsFetchingEbook(true);
try {
const response = await fetchWithAuth(`/api/requests/${requestId}/fetch-ebook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to fetch e-book');
}
if (data.success) {
toast.success(data.message || 'E-book fetched successfully');
} else {
toast.warning(`E-book fetch failed: ${data.message}`);
}
} catch (error) {
console.error('[Admin] Failed to fetch e-book:', error);
toast.error(`Failed to fetch e-book: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsFetchingEbook(false);
}
};
@@ -282,7 +303,9 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
isLoading={isDeleting}
onFetchEbook={handleFetchEbook}
ebookSidecarEnabled={ebookSidecarEnabled}
isLoading={isDeleting || isFetchingEbook}
/>
</td>
</tr>
@@ -21,6 +21,8 @@ export interface RequestActionsDropdownProps {
onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => Promise<void>;
onCancel: (requestId: string) => Promise<void>;
onFetchEbook?: (requestId: string) => Promise<void>;
ebookSidecarEnabled?: boolean;
isLoading?: boolean;
}
@@ -29,6 +31,8 @@ export function RequestActionsDropdown({
onDelete,
onManualSearch,
onCancel,
onFetchEbook,
ebookSidecarEnabled = false,
isLoading = false,
}: RequestActionsDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
@@ -40,6 +44,7 @@ export function RequestActionsDropdown({
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const canDelete = true; // Admins can always delete
const canViewSource = !!request.torrentUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
// Close dropdown when clicking outside
useEffect(() => {
@@ -88,6 +93,17 @@ export function RequestActionsDropdown({
onDelete(request.requestId, request.title);
};
const handleFetchEbook = async () => {
setIsOpen(false);
if (onFetchEbook) {
try {
await onFetchEbook(request.requestId);
} catch (error) {
console.error('Failed to fetch e-book:', error);
}
}
};
return (
<div className="relative" ref={dropdownRef}>
{/* Three-dot menu button */}
@@ -185,8 +201,32 @@ export function RequestActionsDropdown({
</a>
)}
{/* Fetch E-book */}
{canFetchEbook && (
<button
onClick={handleFetchEbook}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
Try to fetch Ebook
</button>
)}
{/* Divider if we have search/view actions and other actions */}
{(canSearch || canViewSource) && (canCancel || canDelete) && (
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
+22 -3
View File
@@ -5,15 +5,15 @@
'use client';
import { useEffect } from 'react';
import useSWR from 'swr';
import Link from 'next/link';
import { authenticatedFetcher } from '@/lib/utils/api';
import { MetricCard } from './components/MetricCard';
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable';
import { ToastProvider } from '@/components/ui/Toast';
export default function AdminDashboard() {
function AdminDashboardContent() {
// Fetch data with auto-refresh every 10 seconds
const { data: metrics, error: metricsError } = useSWR(
'/api/admin/metrics',
@@ -39,6 +39,14 @@ export default function AdminDashboard() {
}
);
const { data: settingsData } = useSWR(
'/api/admin/settings',
authenticatedFetcher,
{
refreshInterval: 60000, // Settings change infrequently
}
);
const isLoading = !metrics || !downloadsData || !requestsData;
const hasError = metricsError || downloadsError || requestsError;
@@ -202,7 +210,10 @@ export default function AdminDashboard() {
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Recent Requests
</h2>
<RecentRequestsTable requests={requestsData.requests} />
<RecentRequestsTable
requests={requestsData.requests}
ebookSidecarEnabled={settingsData?.ebook?.enabled || false}
/>
</div>
{/* Quick Actions */}
@@ -298,3 +309,11 @@ export default function AdminDashboard() {
</div>
);
}
export default function AdminDashboard() {
return (
<ToastProvider>
<AdminDashboardContent />
</ToastProvider>
);
}
@@ -22,7 +22,7 @@ const RequestWithTorrentSchema = z.object({
coverArtUrl: z.string().optional(),
durationMinutes: z.number().optional(),
releaseDate: z.string().optional(),
rating: z.number().optional(),
rating: z.number().nullable().optional(),
}),
torrent: z.object({
guid: z.string(),
@@ -0,0 +1,192 @@
/**
* Component: Fetch E-book API
* Documentation: documentation/integrations/ebook-sidecar.md
*
* Triggers e-book download for a completed request
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { downloadEbook } from '@/lib/services/ebook-scraper';
import fs from 'fs/promises';
import path from 'path';
const DEBUG_ENABLED = process.env.LOG_LEVEL === 'debug';
/**
* Sanitize path component (same logic as file-organizer)
*/
function sanitizePath(name: string): string {
return (
name
.replace(/[<>:"/\\|?*]/g, '')
.trim()
.replace(/^\.+/, '')
.replace(/\.+$/, '')
.replace(/\s+/g, ' ')
.slice(0, 200)
);
}
/**
* Build target path (same logic as file-organizer)
*/
function buildTargetPath(
baseDir: string,
author: string,
title: string,
year?: number | null,
asin?: string | null
): string {
const authorClean = sanitizePath(author);
const titleClean = sanitizePath(title);
let folderName = titleClean;
if (year) {
folderName = `${folderName} (${year})`;
}
if (asin) {
folderName = `${folderName} ${asin}`;
}
return path.join(baseDir, authorClean, folderName);
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
// Check if e-book sidecar is enabled
const ebookEnabledConfig = await prisma.configuration.findUnique({
where: { key: 'ebook_sidecar_enabled' },
});
if (ebookEnabledConfig?.value !== 'true') {
return NextResponse.json(
{ error: 'E-book sidecar feature is not enabled' },
{ status: 400 }
);
}
// Get the request with audiobook data
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'Request not found' },
{ status: 404 }
);
}
// Check if request is in completed state
if (!['downloaded', 'available'].includes(requestRecord.status)) {
return NextResponse.json(
{ error: `Cannot fetch e-book for request in ${requestRecord.status} status` },
{ status: 400 }
);
}
const audiobook = requestRecord.audiobook;
// Get configuration
const [mediaDirConfig, formatConfig, baseUrlConfig, flaresolverrConfig] = await Promise.all([
prisma.configuration.findUnique({ where: { key: 'media_dir' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_preferred_format' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_base_url' } }),
prisma.configuration.findUnique({ where: { key: 'ebook_sidecar_flaresolverr_url' } }),
]);
const mediaDir = mediaDirConfig?.value || '/media/audiobooks';
const preferredFormat = formatConfig?.value || 'epub';
const baseUrl = baseUrlConfig?.value || 'https://annas-archive.li';
const flaresolverrUrl = flaresolverrConfig?.value || undefined;
// Get year from AudibleCache if available
let year: number | undefined;
if (audiobook.audibleAsin) {
const audibleCacheData = await prisma.audibleCache.findUnique({
where: { asin: audiobook.audibleAsin },
select: { releaseDate: true },
});
if (audibleCacheData?.releaseDate) {
year = new Date(audibleCacheData.releaseDate).getFullYear();
}
}
// Build target path
const targetPath = buildTargetPath(
mediaDir,
audiobook.author,
audiobook.title,
year,
audiobook.audibleAsin
);
if (DEBUG_ENABLED) {
console.log(`[FetchEbook] Request: ${id}, Title: "${audiobook.title}", Author: "${audiobook.author}"`);
console.log(`[FetchEbook] Target path: ${targetPath}`);
console.log(`[FetchEbook] Config: format=${preferredFormat}, baseUrl=${baseUrl}, flaresolverr=${flaresolverrUrl || 'none'}`);
}
// Check if target directory exists
try {
await fs.access(targetPath);
} catch {
if (DEBUG_ENABLED) {
console.log(`[FetchEbook] Target directory not found: ${targetPath}`);
}
return NextResponse.json(
{ error: 'Audiobook directory not found. Was the audiobook properly organized?' },
{ status: 400 }
);
}
// Download e-book
const result = await downloadEbook(
audiobook.audibleAsin || '',
audiobook.title,
audiobook.author,
targetPath,
preferredFormat,
baseUrl,
undefined, // No logger in API context
flaresolverrUrl
);
if (result.success) {
console.log(`[FetchEbook] Success: ${result.filePath ? path.basename(result.filePath) : 'unknown'} for "${audiobook.title}"`);
return NextResponse.json({
success: true,
message: `E-book downloaded: ${result.filePath ? path.basename(result.filePath) : 'unknown'}`,
format: result.format,
});
} else {
console.log(`[FetchEbook] Failed for "${audiobook.title}": ${result.error}`);
return NextResponse.json({
success: false,
message: result.error || 'E-book download failed',
});
}
} catch (error) {
console.error('[FetchEbook] Unexpected error:', error instanceof Error ? error.message : error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
{ status: 500 }
);
}
});
});
}
@@ -123,30 +123,18 @@ export async function POST(
author: requestRecord.audiobook.author,
}, indexerPriorities, flagConfigs);
// Dual threshold filtering:
// 1. Base score must be >= 50 (quality minimum)
// 2. Final score must be >= 50 (not disqualified by negative bonuses)
const filteredResults = rankedResults.filter(result =>
result.score >= 50 && result.finalScore >= 50
);
const disqualifiedByNegativeBonus = rankedResults.filter(result =>
result.score >= 50 && result.finalScore < 50
).length;
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100 base + final)`);
if (disqualifiedByNegativeBonus > 0) {
console.log(`[InteractiveSearch] ${disqualifiedByNegativeBonus} torrents disqualified by negative flag bonuses`);
}
// No threshold filtering for interactive search - show all results
// User can see scores and make their own decision
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results (no threshold filter - user decides)`);
// Log top 3 results with detailed score breakdown for debugging
const top3 = filteredResults.slice(0, 3);
const top3 = rankedResults.slice(0, 3);
if (top3.length > 0) {
console.log(`[InteractiveSearch] ==================== RANKING DEBUG ====================`);
console.log(`[InteractiveSearch] Search Query: "${searchQuery}"`);
console.log(`[InteractiveSearch] Requested Title (for ranking): "${requestRecord.audiobook.title}"`);
console.log(`[InteractiveSearch] Requested Author (for ranking): "${requestRecord.audiobook.author}"`);
console.log(`[InteractiveSearch] Top ${top3.length} results (out of ${filteredResults.length} above threshold):`);
console.log(`[InteractiveSearch] Top ${top3.length} results (out of ${rankedResults.length} total):`);
console.log(`[InteractiveSearch] --------------------------------------------------------`);
top3.forEach((result, index) => {
console.log(`[InteractiveSearch] ${index + 1}. "${result.title}"`);
@@ -177,7 +165,7 @@ export async function POST(
}
// Add rank position to each result
const resultsWithRank = filteredResults.map((result, index) => ({
const resultsWithRank = rankedResults.map((result, index) => ({
...result,
rank: index + 1,
}));
@@ -185,9 +173,9 @@ export async function POST(
return NextResponse.json({
success: true,
results: resultsWithRank,
message: filteredResults.length > 0
? `Found ${filteredResults.length} quality matches`
: 'No quality matches found',
message: rankedResults.length > 0
? `Found ${rankedResults.length} results`
: 'No results found',
});
} catch (error) {
console.error('Failed to perform interactive search:', error);
+1 -1
View File
@@ -20,7 +20,7 @@ const CreateRequestSchema = z.object({
coverArtUrl: z.string().optional(),
durationMinutes: z.number().optional(),
releaseDate: z.string().optional(),
rating: z.number().optional(),
rating: z.number().nullable().optional(),
}),
});