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