From 722a78ac339f2b87078cb923fab14e99225cb122 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Thu, 8 Jan 2026 02:09:57 -0500 Subject: [PATCH] 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. --- .../admin/components/RecentRequestsTable.tsx | 57 ++++-- .../components/RequestActionsDropdown.tsx | 42 +++- src/app/admin/page.tsx | 25 ++- .../audiobooks/request-with-torrent/route.ts | 2 +- .../api/requests/[id]/fetch-ebook/route.ts | 192 ++++++++++++++++++ .../requests/[id]/interactive-search/route.ts | 30 +-- src/app/api/requests/route.ts | 2 +- .../InteractiveTorrentSearchModal.tsx | 2 +- src/lib/integrations/prowlarr.service.ts | 31 ++- src/lib/utils/ranking-algorithm.ts | 16 +- 10 files changed, 342 insertions(+), 57 deletions(-) create mode 100644 src/app/api/requests/[id]/fetch-ebook/route.ts diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx index 14c1e5a..f165669 100644 --- a/src/app/admin/components/RecentRequestsTable.tsx +++ b/src/app/admin/components/RecentRequestsTable.tsx @@ -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} /> diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index 4b582c3..86762e5 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -21,6 +21,8 @@ export interface RequestActionsDropdownProps { onDelete: (requestId: string, title: string) => void; onManualSearch: (requestId: string) => Promise; onCancel: (requestId: string) => Promise; + onFetchEbook?: (requestId: string) => Promise; + 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 (
{/* Three-dot menu button */} @@ -185,8 +201,32 @@ export function RequestActionsDropdown({ )} + {/* Fetch E-book */} + {canFetchEbook && ( + + )} + {/* Divider if we have search/view actions and other actions */} - {(canSearch || canViewSource) && (canCancel || canDelete) && ( + {(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
)} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 9f87d2e..6199543 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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() {

Recent Requests

- +
{/* Quick Actions */} @@ -298,3 +309,11 @@ export default function AdminDashboard() {
); } + +export default function AdminDashboard() { + return ( + + + + ); +} diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts index 816ee36..5bdecbd 100644 --- a/src/app/api/audiobooks/request-with-torrent/route.ts +++ b/src/app/api/audiobooks/request-with-torrent/route.ts @@ -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(), diff --git a/src/app/api/requests/[id]/fetch-ebook/route.ts b/src/app/api/requests/[id]/fetch-ebook/route.ts new file mode 100644 index 0000000..05212c7 --- /dev/null +++ b/src/app/api/requests/[id]/fetch-ebook/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index 9cafa88..8ec7683 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -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); diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 06f3714..f9902fc 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -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(), }), }); diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index e9de5a4..fe40f11 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -235,7 +235,7 @@ export function InteractiveTorrentSearchModal({
0) { + // Debug: Log first raw result to see structure (debug mode only) + if (process.env.LOG_LEVEL === 'debug' && response.data.length > 0) { console.log('[Prowlarr] Sample raw result from API:', JSON.stringify(response.data[0], null, 2)); + console.log(`[Prowlarr] Received ${response.data.length} total results from API`); } // Transform Prowlarr results to our format const results = response.data - .map((result: ProwlarrSearchResult) => this.transformResult(result)) + .map((result: ProwlarrSearchResult, index: number) => { + const transformed = this.transformResult(result); + if (!transformed && process.env.LOG_LEVEL === 'debug') { + // Log the full raw result that was skipped (debug mode only) + console.log(`[Prowlarr] Result #${index + 1} was skipped. Raw data:`, JSON.stringify(result, null, 2)); + } + return transformed; + }) .filter((result: TorrentResult | null) => result !== null) as TorrentResult[]; // Filter by protocol based on configured download client @@ -251,6 +261,7 @@ export class ProwlarrService { leechers, publishDate: item.pubDate ? new Date(item.pubDate) : new Date(), downloadUrl: downloadUrl.trim(), + infoUrl: item.comments || undefined, // RSS feeds often have comments field with info URL infoHash: getAttr('infohash'), guid: item.guid || '', format: metadata.format, @@ -352,9 +363,12 @@ export class ProwlarrService { */ private transformResult(result: ProwlarrSearchResult): TorrentResult | null { try { - // Validate download URL - if (!result.downloadUrl || typeof result.downloadUrl !== 'string' || result.downloadUrl.trim() === '') { - console.warn(`[Prowlarr] Skipping result "${result.title}" - missing download URL`); + // Get download URL - prefer downloadUrl (torrent file), fallback to magnetUrl (magnet link) + const downloadUrl = result.downloadUrl || result.magnetUrl || ''; + + // Validate we have a valid download URL + if (!downloadUrl || typeof downloadUrl !== 'string' || downloadUrl.trim() === '') { + console.warn(`[Prowlarr] Skipping result "${result.title}" - missing both downloadUrl and magnetUrl`); return null; } @@ -372,7 +386,8 @@ export class ProwlarrService { seeders: result.seeders, leechers: result.leechers, publishDate: new Date(result.publishDate), - downloadUrl: result.downloadUrl.trim(), + downloadUrl: downloadUrl.trim(), + infoUrl: result.infoUrl, infoHash: result.infoHash, guid: result.guid, format: metadata.format, diff --git a/src/lib/utils/ranking-algorithm.ts b/src/lib/utils/ranking-algorithm.ts index 08bf728..f84758a 100644 --- a/src/lib/utils/ranking-algorithm.ts +++ b/src/lib/utils/ranking-algorithm.ts @@ -14,6 +14,7 @@ export interface TorrentResult { leechers: number; publishDate: Date; downloadUrl: string; + infoUrl?: string; // Link to indexer's info page (for user reference) infoHash?: string; guid: string; format?: 'M4B' | 'M4A' | 'MP3' | 'OTHER'; @@ -273,9 +274,10 @@ export class RankingAlgorithm { torrent: TorrentResult, audiobook: AudiobookRequest ): number { - const torrentTitle = torrent.title.toLowerCase(); - const requestTitle = audiobook.title.toLowerCase(); - const requestAuthor = audiobook.author.toLowerCase(); + // Normalize whitespace (multiple spaces → single space) for consistent matching + const torrentTitle = torrent.title.toLowerCase().replace(/\s+/g, ' ').trim(); + const requestTitle = audiobook.title.toLowerCase().replace(/\s+/g, ' ').trim(); + const requestAuthor = audiobook.author.toLowerCase().replace(/\s+/g, ' ').trim(); // ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ========== // Extract significant words (filter out common stop words) @@ -353,8 +355,14 @@ export class RankingAlgorithm { // 1. Acceptable prefix (no words, OR structured metadata like "Author - Series - ") // 2. Followed by clear metadata markers (not "'s Secret" or " Is Watching") const metadataMarkers = [' by ', ' - ', ' [', ' (', ' {', ' :', ',']; + + // Check if afterTitle starts with author name (handles space-separated format like "Title Author Year") + const afterStartsWithAuthor = requestAuthor.length > 2 && + afterTitle.trim().startsWith(requestAuthor); + const hasMetadataSuffix = afterTitle === '' || - metadataMarkers.some(marker => afterTitle.startsWith(marker)); + metadataMarkers.some(marker => afterTitle.startsWith(marker)) || + afterStartsWithAuthor; // Check prefix validity: // - No words before = clean match