From d25a6ebf790c6ed32c7cb3abcdb00941aa349a9d Mon Sep 17 00:00:00 2001 From: kikootwo Date: Mon, 2 Mar 2026 17:05:21 -0500 Subject: [PATCH] Add custom search terms & retry download (admin) Add support for per-request custom search terms and an admin retry-download flow. - DB/schema: add custom_search_terms column via Prisma migration and schema update. - Admin UI: new AdjustSearchTermsModal component and UI badges to show custom search status; RequestActionsDropdown and RecentRequestsTable updated to surface adjust/retry actions. - API: new PATCH /api/admin/requests/[id]/search-terms to set/clear custom terms (optionally trigger a new search) and new POST /api/admin/requests/[id]/retry-download to resume monitoring or re-add downloads using DownloadHistory metadata. - Behavior: interactive search now prefers customSearchTerms when present; manual import exposes cleanupSource option to organize job; admin requests listing returns downloadAttempts and customSearchTerms. - UX: add SectionToolbar, LoadMoreBar and HideAvailableToggle components and wire hide-available preference across home, search, author and series pages; authors/series endpoints/page handlers gain pagination metadata. - Misc: add connection-errors util and update related processors/services and tests to cover the new flows. These changes enable admins to override search terms per request, trigger searches from the admin UI, and retry failed downloads more robustly. --- .../migration.sql | 2 + prisma/schema.prisma | 1 + .../components/AdjustSearchTermsModal.tsx | 154 ++++++++++ .../admin/components/RecentRequestsTable.tsx | 41 ++- .../components/RequestActionsDropdown.tsx | 97 ++++++- src/app/api/admin/manual-import/route.ts | 4 +- .../requests/[id]/retry-download/route.ts | 271 ++++++++++++++++++ .../admin/requests/[id]/search-terms/route.ts | 133 +++++++++ src/app/api/admin/requests/route.ts | 2 + src/app/api/authors/[asin]/books/route.ts | 14 +- .../requests/[id]/interactive-search/route.ts | 4 +- src/app/api/series/[asin]/route.ts | 10 +- src/app/authors/[asin]/page.tsx | 54 +++- src/app/page.tsx | 47 ++- src/app/search/page.tsx | 78 ++--- src/app/series/[asin]/page.tsx | 58 +++- .../audiobooks/ManualImportBrowser.tsx | 6 + .../audiobooks/manual-import/ConfirmPhase.tsx | 28 ++ .../InteractiveTorrentSearchModal.tsx | 8 +- src/components/ui/HideAvailableToggle.tsx | 81 ++++++ src/components/ui/LoadMoreBar.tsx | 82 ++++++ src/components/ui/SectionToolbar.tsx | 175 +++++++++++ src/contexts/PreferencesContext.tsx | 28 +- src/lib/hooks/useAudiobooks.ts | 62 +++- src/lib/hooks/useAuthors.ts | 64 ++++- src/lib/hooks/useSeries.ts | 64 ++++- src/lib/integrations/audible-series.ts | 15 +- src/lib/integrations/audible.service.ts | 189 ++++++------ .../processors/download-torrent.processor.ts | 26 +- .../processors/monitor-download.processor.ts | 126 +++++--- .../processors/organize-files.processor.ts | 76 ++++- .../processors/search-indexers.processor.ts | 15 +- src/lib/services/job-queue.service.ts | 33 ++- .../providers/apprise.provider.ts | 24 +- src/lib/utils/connection-errors.ts | 80 ++++++ .../RequestActionsDropdown.test.tsx | 4 + tests/app/search.page.test.tsx | 68 +++-- tests/lib/hooks/useAudiobooks.test.tsx | 25 +- tests/services/apprise.provider.test.ts | 96 +++++++ 39 files changed, 2034 insertions(+), 311 deletions(-) create mode 100644 prisma/migrations/20260302000000_add_custom_search_terms/migration.sql create mode 100644 src/app/admin/components/AdjustSearchTermsModal.tsx create mode 100644 src/app/api/admin/requests/[id]/retry-download/route.ts create mode 100644 src/app/api/admin/requests/[id]/search-terms/route.ts create mode 100644 src/components/ui/HideAvailableToggle.tsx create mode 100644 src/components/ui/LoadMoreBar.tsx create mode 100644 src/components/ui/SectionToolbar.tsx create mode 100644 src/lib/utils/connection-errors.ts diff --git a/prisma/migrations/20260302000000_add_custom_search_terms/migration.sql b/prisma/migrations/20260302000000_add_custom_search_terms/migration.sql new file mode 100644 index 0000000..3949713 --- /dev/null +++ b/prisma/migrations/20260302000000_add_custom_search_terms/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "requests" ADD COLUMN "custom_search_terms" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0d5a26..07d0d15 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -232,6 +232,7 @@ model Request { importAttempts Int @default(0) @map("import_attempts") maxImportRetries Int @default(5) @map("max_import_retries") lastSearchAt DateTime? @map("last_search_at") + customSearchTerms String? @map("custom_search_terms") @db.Text lastImportAt DateTime? @map("last_import_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/admin/components/AdjustSearchTermsModal.tsx b/src/app/admin/components/AdjustSearchTermsModal.tsx new file mode 100644 index 0000000..cb8b6fa --- /dev/null +++ b/src/app/admin/components/AdjustSearchTermsModal.tsx @@ -0,0 +1,154 @@ +/** + * Component: Adjust Search Terms Modal + * Documentation: documentation/admin-dashboard.md + */ + +'use client'; + +import { useState } from 'react'; +import { Modal } from '@/components/ui/Modal'; +import { fetchWithAuth } from '@/lib/utils/api'; +import { useToast } from '@/components/ui/Toast'; + +interface AdjustSearchTermsModalProps { + isOpen: boolean; + onClose: () => void; + requestId: string; + title: string; + author: string; + currentSearchTerms?: string | null; + onSuccess?: () => void; +} + +export function AdjustSearchTermsModal({ + isOpen, + onClose, + requestId, + title, + author, + currentSearchTerms, + onSuccess, +}: AdjustSearchTermsModalProps) { + const toast = useToast(); + const [searchTerms, setSearchTerms] = useState(currentSearchTerms || title); + const [isSaving, setIsSaving] = useState(false); + const [isSavingAndSearching, setIsSavingAndSearching] = useState(false); + + // Reset state when modal opens + const handleClose = () => { + setSearchTerms(currentSearchTerms || title); + onClose(); + }; + + const save = async (triggerSearch: boolean) => { + const setter = triggerSearch ? setIsSavingAndSearching : setIsSaving; + setter(true); + + try { + // If terms match the original title, clear the override + const termsToSave = searchTerms.trim() === title ? null : searchTerms.trim() || null; + + const response = await fetchWithAuth(`/api/admin/requests/${requestId}/search-terms`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ searchTerms: termsToSave, triggerSearch }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to update search terms'); + } + + const data = await response.json(); + + if (data.searchTriggered) { + toast.success('Search terms saved and search triggered'); + } else { + toast.success('Search terms saved'); + } + + onSuccess?.(); + onClose(); + } catch (error) { + toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setter(false); + } + }; + + const handleReset = () => { + setSearchTerms(title); + }; + + const isLoading = isSaving || isSavingAndSearching; + const hasChanges = searchTerms.trim() !== (currentSearchTerms || title); + const isCustom = searchTerms.trim() !== title; + + return ( + +
+ {/* Original info */} +
+
+ Original Title +
+
{title}
+
by {author}
+
+ + {/* Search terms input */} +
+ + setSearchTerms(e.target.value)} + disabled={isLoading} + className="w-full 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 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50" + placeholder="Enter custom search terms..." + /> + {isCustom && ( + + )} +
+ + {/* Actions */} +
+ + + +
+
+
+ ); +} diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx index 81958da..a4812ae 100644 --- a/src/app/admin/components/RecentRequestsTable.tsx +++ b/src/app/admin/components/RecentRequestsTable.tsx @@ -28,6 +28,8 @@ interface RecentRequest { completedAt: Date | null; errorMessage: string | null; torrentUrl?: string | null; + downloadAttempts?: number; + customSearchTerms?: string | null; } interface User { @@ -444,6 +446,29 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB } }; + const handleRetryDownload = async (requestId: string) => { + try { + const response = await fetchWithAuth(`/api/admin/requests/${requestId}/retry-download`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const responseData = await response.json(); + + if (!response.ok) { + throw new Error(responseData.message || 'Failed to retry download'); + } + + toast.success(responseData.message || 'Download retry initiated'); + await mutate(apiUrl); + } catch (error) { + console.error('[Admin] Failed to retry download:', error); + toast.error(`Failed to retry download: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + // Render loading state if (isLoading && !data) { return ( @@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB Ebook )} + {request.customSearchTerms && ( + + + + + Custom Search + + )}
{request.author} @@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB type: request.type, asin: request.asin, torrentUrl: request.torrentUrl, + downloadAttempts: request.downloadAttempts, + customSearchTerms: request.customSearchTerms, }} onDelete={handleDeleteClick} onManualSearch={handleManualSearch} onCancel={handleCancel} + onRetryDownload={handleRetryDownload} onViewDetails={(asin) => handleViewDetails(asin, request.status)} onFetchEbook={handleFetchEbook} + onSearchTermsUpdated={() => mutate(apiUrl)} ebookSidecarEnabled={ebookSidecarEnabled} annasArchiveBaseUrl={annasArchiveBaseUrl} isLoading={isDeleting || isFetchingEbook} @@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB }} isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'} requestStatus={viewDetailsStatus} - hideRequestActions /> )}
diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index b9c3453..9de721c 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -10,6 +10,7 @@ import { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; +import { AdjustSearchTermsModal } from './AdjustSearchTermsModal'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; export interface RequestActionsDropdownProps { @@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps { type?: 'audiobook' | 'ebook'; asin?: string | null; torrentUrl?: string | null; + downloadAttempts?: number; + customSearchTerms?: string | null; }; onDelete: (requestId: string, title: string) => void; onManualSearch: (requestId: string) => Promise; onCancel: (requestId: string) => Promise; + onRetryDownload?: (requestId: string) => Promise; onViewDetails?: (asin: string) => void; onFetchEbook?: (requestId: string) => Promise; + onSearchTermsUpdated?: () => void; ebookSidecarEnabled?: boolean; annasArchiveBaseUrl?: string; isLoading?: boolean; @@ -37,8 +42,10 @@ export function RequestActionsDropdown({ onDelete, onManualSearch, onCancel, + onRetryDownload, onViewDetails, onFetchEbook, + onSearchTermsUpdated, ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li', isLoading = false, @@ -46,6 +53,7 @@ export function RequestActionsDropdown({ const [isOpen, setIsOpen] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); + const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen); // Determine request type @@ -57,6 +65,8 @@ export function RequestActionsDropdown({ // Determine available actions based on status and type // Ebooks don't support manual/interactive search (Anna's Archive only) const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); + const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status); + const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload; const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); const canDelete = true; // Admins can always delete @@ -123,11 +133,27 @@ export function RequestActionsDropdown({ setShowInteractiveSearch(true); }; + const handleAdjustSearchTerms = () => { + setIsOpen(false); + setShowAdjustSearchTerms(true); + }; + const handleInteractiveSearchEbook = () => { setIsOpen(false); setShowInteractiveSearchEbook(true); }; + const handleRetryDownload = async () => { + setIsOpen(false); + if (onRetryDownload) { + try { + await onRetryDownload(request.requestId); + } catch (error) { + console.error('Failed to retry download:', error); + } + } + }; + const handleCancel = async () => { setIsOpen(false); if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) { @@ -253,6 +279,35 @@ export function RequestActionsDropdown({ )} + {/* Adjust Search Terms */} + {canAdjustSearchTerms && ( + + )} + {/* View Source */} {canViewSource && viewSourceUrl && ( )} - {/* Divider if we have search/view actions and other actions */} - {(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && ( + {/* Retry Download */} + {canRetryDownload && ( + + )} + + {/* Divider if we have search/view/retry actions and other actions */} + {(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
)} @@ -358,7 +437,7 @@ export function RequestActionsDropdown({ )} {/* Divider before delete */} - {canDelete && (canSearch || canCancel) && ( + {canDelete && (canSearch || canRetryDownload || canCancel) && (
)} @@ -421,6 +500,7 @@ export function RequestActionsDropdown({ title: request.title, author: request.author, }} + customSearchTerms={request.customSearchTerms} /> {/* Interactive Search Modal (Ebook) */} @@ -434,6 +514,17 @@ export function RequestActionsDropdown({ }} searchMode="ebook" /> + + {/* Adjust Search Terms Modal */} + setShowAdjustSearchTerms(false)} + requestId={request.requestId} + title={request.title} + author={request.author} + currentSearchTerms={request.customSearchTerms} + onSuccess={onSearchTermsUpdated} + /> ); } diff --git a/src/app/api/admin/manual-import/route.ts b/src/app/api/admin/manual-import/route.ts index 3117e8c..d2aa482 100644 --- a/src/app/api/admin/manual-import/route.ts +++ b/src/app/api/admin/manual-import/route.ts @@ -54,7 +54,7 @@ export async function POST(request: NextRequest) { const fs = await import('fs/promises'); const body = await request.json(); - const { folderPath, asin } = body; + const { folderPath, asin, cleanupSource } = body; let { audiobookId } = body; // Validate required fields @@ -242,7 +242,7 @@ export async function POST(request: NextRequest) { // Queue organize_files job const jobQueue = getJobQueueService(); - await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath); + await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true); logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`); diff --git a/src/app/api/admin/requests/[id]/retry-download/route.ts b/src/app/api/admin/requests/[id]/retry-download/route.ts new file mode 100644 index 0000000..27845c5 --- /dev/null +++ b/src/app/api/admin/requests/[id]/retry-download/route.ts @@ -0,0 +1,271 @@ +/** + * Component: Admin Retry Download API + * Documentation: documentation/admin-dashboard.md + * + * Retries a failed download by either resuming monitoring of a still-alive + * download in the client, or re-adding the download using metadata from the + * most recent selected DownloadHistory record. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getConfigService } from '@/lib/services/config.service'; +import { getDownloadClientManager } from '@/lib/services/download-client-manager.service'; +import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '@/lib/interfaces/download-client.interface'; +import { TorrentResult } from '@/lib/utils/ranking-algorithm'; +import { RMABLogger } from '@/lib/utils/logger'; + +const logger = RMABLogger.create('API.Admin.Requests.RetryDownload'); + +/** Download statuses considered "alive" — monitoring can be resumed */ +const ALIVE_STATUSES = new Set([ + 'downloading', + 'queued', + 'paused', + 'checking', + 'seeding', + 'completed', +]); + +/** + * POST /api/admin/requests/[id]/retry-download + * Retry a failed download for an admin request. + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + if (!req.user) { + return NextResponse.json( + { error: 'Unauthorized', message: 'User not authenticated' }, + { status: 401 } + ); + } + + const { id } = await params; + + // Fetch the request with audiobook info + const existingRequest = await prisma.request.findFirst({ + where: { id, deletedAt: null }, + include: { + audiobook: true, + }, + }); + + if (!existingRequest) { + return NextResponse.json( + { error: 'NotFound', message: 'Request not found' }, + { status: 404 } + ); + } + + if (existingRequest.status !== 'failed') { + return NextResponse.json( + { + error: 'InvalidStatus', + message: `Request is not in a failed state (current status: ${existingRequest.status})`, + currentStatus: existingRequest.status, + }, + { status: 400 } + ); + } + + // Find the most recent selected DownloadHistory record + const downloadHistory = await prisma.downloadHistory.findFirst({ + where: { requestId: id, selected: true }, + orderBy: { createdAt: 'desc' }, + }); + + if (!downloadHistory) { + return NextResponse.json( + { + error: 'NoHistory', + message: 'No previous download attempt found to retry', + }, + { status: 400 } + ); + } + + // Require a download URL to be able to re-add + if (!downloadHistory.magnetLink) { + return NextResponse.json( + { + error: 'NoDownloadUrl', + message: 'No download URL available in history to retry', + }, + { status: 400 } + ); + } + + const jobQueue = getJobQueueService(); + let retryPath: 'resumed_monitoring' | 're_added'; + + // Determine if we can attempt to resume monitoring. + // downloadClient is stored as a plain string in the DB (can be 'qbittorrent', 'sabnzbd', + // 'nzbget', 'transmission', 'deluge', 'direct', or null). + const rawClientType: string | null = downloadHistory.downloadClient; + const clientId = downloadHistory.downloadClientId; + const isDirect = rawClientType === 'direct'; + + // Only attempt to query the download client if we have a known DownloadClientType, + // a clientId, and it is not a direct (HTTP) download. + const canCheckClient = !isDirect && !!rawClientType && !!clientId; + // Safe to cast here: we have already confirmed rawClientType is non-null and non-direct + const clientType = rawClientType as DownloadClientType | null; + + if (canCheckClient) { + // Try to look up the download in the client + try { + const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType]; + const configService = getConfigService(); + const manager = getDownloadClientManager(configService); + const client = await manager.getClientServiceForProtocol(protocol); + + if (client) { + const downloadInfo = await client.getDownload(clientId!); + + if (downloadInfo && ALIVE_STATUSES.has(downloadInfo.status)) { + // Download is still alive — restart monitoring + logger.info(`Retry download: resuming monitoring for request ${id}`, { + requestId: id, + downloadClientId: clientId, + downloadStatus: downloadInfo.status, + adminId: req.user.sub, + }); + + await jobQueue.addMonitorJob( + id, + downloadHistory.id, + clientId!, // canCheckClient guard ensures clientId is non-null + clientType as DownloadClientType, + 0 // no delay — start immediately + ); + + retryPath = 'resumed_monitoring'; + } else { + // Download not found or is failed — re-add + logger.info(`Retry download: download not alive (status: ${downloadInfo?.status ?? 'not found'}), re-adding for request ${id}`, { + requestId: id, + adminId: req.user.sub, + }); + + await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory); + retryPath = 're_added'; + } + } else { + // No client configured for that protocol — fall through to re-add + logger.warn(`Retry download: no ${protocol} client configured, re-adding for request ${id}`, { + requestId: id, + adminId: req.user.sub, + }); + + await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory); + retryPath = 're_added'; + } + } catch (clientError) { + // Client lookup failed (connection error etc.) — re-add to be safe + logger.warn(`Retry download: client check failed, re-adding for request ${id}`, { + requestId: id, + error: clientError instanceof Error ? clientError.message : String(clientError), + adminId: req.user.sub, + }); + + await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory); + retryPath = 're_added'; + } + } else { + // Direct download (ebook), no clientId, or no clientType — re-add + logger.info(`Retry download: re-adding for request ${id} (direct=${isDirect}, hasClientId=${!!clientId})`, { + requestId: id, + adminId: req.user.sub, + }); + + await reAddDownload(jobQueue, id, existingRequest.audiobook, downloadHistory); + retryPath = 're_added'; + } + + // Increment downloadAttempts, clear errorMessage, set status to downloading + await prisma.request.update({ + where: { id }, + data: { + status: 'downloading', + errorMessage: null, + downloadAttempts: { increment: 1 }, + updatedAt: new Date(), + }, + }); + + const message = + retryPath === 'resumed_monitoring' + ? 'Download monitoring resumed' + : 'Download re-added to client'; + + logger.info(`Retry download completed for request ${id} via ${retryPath}`, { + requestId: id, + adminId: req.user.sub, + path: retryPath, + }); + + return NextResponse.json({ + success: true, + message, + path: retryPath, + }); + } catch (error) { + logger.error('Failed to retry download', { + error: error instanceof Error ? error.message : String(error), + }); + + return NextResponse.json( + { + error: 'RetryError', + message: 'Failed to retry download', + }, + { status: 500 } + ); + } + }); + }); +} + +/** + * Re-add the download to the queue using metadata from DownloadHistory. + * Reconstructs a TorrentResult from the stored history fields. + */ +async function reAddDownload( + jobQueue: ReturnType, + requestId: string, + audiobook: { id: string; title: string; author: string }, + history: { + torrentName: string | null; + magnetLink: string | null; + indexerName: string; + indexerId: number | null; + torrentSizeBytes: bigint | null; + seeders: number | null; + leechers: number | null; + torrentHash: string | null; + torrentUrl: string | null; + } +): Promise { + const torrent: TorrentResult = { + title: history.torrentName ?? audiobook.title, + downloadUrl: history.magnetLink!, // Validated non-null before calling this function + indexer: history.indexerName, + indexerId: history.indexerId ?? undefined, + size: history.torrentSizeBytes !== null ? Number(history.torrentSizeBytes) : 0, + seeders: history.seeders ?? undefined, + leechers: history.leechers ?? undefined, + infoHash: history.torrentHash ?? undefined, + infoUrl: history.torrentUrl ?? undefined, + guid: history.torrentUrl ?? history.magnetLink!, + publishDate: new Date(), // Not stored; use current date as a safe default + }; + + await jobQueue.addDownloadJob(requestId, audiobook, torrent); +} diff --git a/src/app/api/admin/requests/[id]/search-terms/route.ts b/src/app/api/admin/requests/[id]/search-terms/route.ts new file mode 100644 index 0000000..65032b1 --- /dev/null +++ b/src/app/api/admin/requests/[id]/search-terms/route.ts @@ -0,0 +1,133 @@ +/** + * Component: Admin Custom Search Terms API + * 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'; + +const logger = RMABLogger.create('API.Admin.SearchTerms'); + +/** + * PATCH /api/admin/requests/[id]/search-terms + * Update custom search terms for a request (admin only) + * Body: { searchTerms: string | null, triggerSearch?: boolean } + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + if (!req.user) { + return NextResponse.json( + { error: 'Unauthorized', message: 'User not authenticated' }, + { status: 401 } + ); + } + + const { id } = await params; + + // Parse body + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: 'BadRequest', message: 'Invalid JSON body' }, + { status: 400 } + ); + } + + const { searchTerms, triggerSearch } = body; + + // Validate searchTerms is string or null + if (searchTerms !== null && searchTerms !== undefined && typeof searchTerms !== 'string') { + return NextResponse.json( + { error: 'BadRequest', message: 'searchTerms must be a string or null' }, + { status: 400 } + ); + } + + // Trim and normalize + const normalizedTerms = typeof searchTerms === 'string' ? searchTerms.trim() || null : null; + + // Find the request + const existingRequest = await prisma.request.findUnique({ + where: { id }, + include: { + audiobook: { + select: { id: true, title: true, author: true, audibleAsin: true }, + }, + }, + }); + + if (!existingRequest || existingRequest.deletedAt) { + return NextResponse.json( + { error: 'NotFound', message: 'Request not found' }, + { status: 404 } + ); + } + + // Update custom search terms + await prisma.request.update({ + where: { id }, + data: { + customSearchTerms: normalizedTerms, + updatedAt: new Date(), + }, + }); + + logger.info(`Custom search terms ${normalizedTerms ? 'set' : 'cleared'} for request ${id}`, { + requestId: id, + customSearchTerms: normalizedTerms, + adminId: req.user.id, + }); + + // Optionally trigger a new search + let searchTriggered = false; + if (triggerSearch && ['pending', 'failed', 'awaiting_search'].includes(existingRequest.status)) { + // Reset status to pending and clear error + await prisma.request.update({ + where: { id }, + data: { + status: 'pending', + errorMessage: null, + updatedAt: new Date(), + }, + }); + + // Queue search job + const { getJobQueueService } = await import('@/lib/services/job-queue.service'); + const jobQueue = getJobQueueService(); + await jobQueue.addSearchJob(id, { + id: existingRequest.audiobook.id, + title: existingRequest.audiobook.title, + author: existingRequest.audiobook.author, + asin: existingRequest.audiobook.audibleAsin || undefined, + }); + + searchTriggered = true; + logger.info(`Search triggered for request ${id} with custom terms`, { requestId: id }); + } + + return NextResponse.json({ + success: true, + customSearchTerms: normalizedTerms, + searchTriggered, + }); + } catch (error) { + logger.error('Failed to update search terms', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'ServerError', message: 'Failed to update search terms' }, + { status: 500 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/requests/route.ts b/src/app/api/admin/requests/route.ts index 774c3d1..94f3564 100644 --- a/src/app/api/admin/requests/route.ts +++ b/src/app/api/admin/requests/route.ts @@ -139,6 +139,8 @@ export async function GET(request: NextRequest) { completedAt: request.completedAt, errorMessage: request.errorMessage, torrentUrl: request.downloadHistory[0]?.torrentUrl || null, + downloadAttempts: request.downloadAttempts, + customSearchTerms: request.customSearchTerms || null, })); return NextResponse.json({ diff --git a/src/app/api/authors/[asin]/books/route.ts b/src/app/api/authors/[asin]/books/route.ts index 83368b5..0535d73 100644 --- a/src/app/api/authors/[asin]/books/route.ts +++ b/src/app/api/authors/[asin]/books/route.ts @@ -46,23 +46,27 @@ export async function GET( ); } - logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin})`); + const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10); + + logger.info(`Fetching books for author "${authorName}" (ASIN: ${asin}), page ${page}`); const audibleService = getAudibleService(); - const books = await audibleService.searchByAuthorAsin(authorName.trim(), asin); + const result = await audibleService.searchByAuthorAsin(authorName.trim(), asin, page); // Enrich with library availability and request status const userId = currentUser.sub || undefined; - const enrichedBooks = await enrichAudiobooksWithMatches(books, userId); + const enrichedBooks = await enrichAudiobooksWithMatches(result.books, userId); - logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books`); + logger.info(`Author books complete: "${authorName}" → ${enrichedBooks.length} books (page ${page})`); return NextResponse.json({ success: true, books: enrichedBooks, authorName: authorName.trim(), authorAsin: asin, - totalBooks: enrichedBooks.length, + totalBooks: result.totalResults || enrichedBooks.length, + hasMore: result.hasMore, + page: result.page, }); } catch (error) { logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) }); diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index 98e9a39..db39906 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -125,8 +125,8 @@ export async function POST( logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`); } - // Use custom title if provided, otherwise use audiobook's title - const searchTitle = customTitle || requestRecord.audiobook.title; + // Use custom title if provided, then custom search terms, then audiobook's title + const searchTitle = customTitle || requestRecord.customSearchTerms || requestRecord.audiobook.title; const searchAuthor = requestRecord.audiobook.author; logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle }); diff --git a/src/app/api/series/[asin]/route.ts b/src/app/api/series/[asin]/route.ts index e277c06..43271fb 100644 --- a/src/app/api/series/[asin]/route.ts +++ b/src/app/api/series/[asin]/route.ts @@ -37,9 +37,11 @@ export async function GET( ); } - logger.info(`Fetching series detail: ${asin}`); + const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10); - const detail = await scrapeSeriesPage(asin); + logger.info(`Fetching series detail: ${asin}, page ${page}`); + + const detail = await scrapeSeriesPage(asin, page); if (!detail) { return NextResponse.json( { error: 'NotFound', message: 'Series not found' }, @@ -51,7 +53,7 @@ export async function GET( const userId = currentUser.sub || undefined; const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId); - logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books)`); + logger.info(`Series detail complete: "${detail.title}" (${enrichedBooks.length} books, page ${page})`); return NextResponse.json({ success: true, @@ -59,6 +61,8 @@ export async function GET( ...detail, books: enrichedBooks, }, + hasMore: detail.hasMore, + page: detail.page, }); } catch (error) { logger.error('Failed to fetch series detail', { diff --git a/src/app/authors/[asin]/page.tsx b/src/app/authors/[asin]/page.tsx index accee7e..eed5ecd 100644 --- a/src/app/authors/[asin]/page.tsx +++ b/src/app/authors/[asin]/page.tsx @@ -5,16 +5,17 @@ 'use client'; -import { use, useCallback } from 'react'; +import { use, useCallback, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Header } from '@/components/layout/Header'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; +import { LoadMoreBar } from '@/components/ui/LoadMoreBar'; import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard'; import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow'; import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors'; +import { Audiobook } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; -import { CardSizeControls } from '@/components/ui/CardSizeControls'; -import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle'; +import { SectionToolbar } from '@/components/ui/SectionToolbar'; import { usePreferences } from '@/contexts/PreferencesContext'; export default function AuthorDetailPage({ @@ -27,11 +28,11 @@ export default function AuthorDetailPage({ const searchParams = useSearchParams(); const fromAuthorName = searchParams.get('from'); const { author, isLoading: authorLoading } = useAuthorDetail(asin); - const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks( + const { books, totalBooks, hasMore, isLoading: booksLoading, isLoadingMore, loadMore } = useAuthorBooks( asin, author?.name || null ); - const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences(); + const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences(); const handleBack = useCallback(() => { // Use browser back if we came from within the app, otherwise fallback to /authors @@ -42,6 +43,20 @@ export default function AuthorDetailPage({ } }, [router]); + // Filter out available titles when hideAvailable is enabled + const filteredBooks = useMemo( + () => hideAvailable ? books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : books, + [books, hideAvailable] + ); + + // Header count text: reflects filtered counts + const visibleCount = filteredBooks.length; + const booksCountText = hasMore && totalBooks > books.length + ? `${visibleCount.toLocaleString()} of ${totalBooks.toLocaleString()} title${totalBooks !== 1 ? 's' : ''}` + : visibleCount > 0 + ? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}` + : ''; + return (
@@ -91,27 +106,42 @@ export default function AuthorDetailPage({

Books

- {!booksLoading && totalBooks > 0 && ( + {!booksLoading && booksCountText && ( - ({totalBooks} title{totalBooks !== 1 ? 's' : ''}) + ({booksCountText}) )} -
- - -
+
{/* Books Grid */} + + {/* Load More Bar */} + {filteredBooks.length > 0 && ( + 0 ? totalBooks : undefined} + hasMore={hasMore} + isLoading={isLoadingMore} + onLoadMore={loadMore} + /> + )} )} diff --git a/src/app/page.tsx b/src/app/page.tsx index b760dc1..af8429d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,20 +5,19 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useState, useRef, useMemo } from 'react'; import { Header } from '@/components/layout/Header'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; -import { useAudiobooks } from '@/lib/hooks/useAudiobooks'; +import { useAudiobooks, Audiobook } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; import { StickyPagination } from '@/components/ui/StickyPagination'; -import { CardSizeControls } from '@/components/ui/CardSizeControls'; -import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle'; +import { SectionToolbar } from '@/components/ui/SectionToolbar'; import { usePreferences } from '@/contexts/PreferencesContext'; export default function HomePage() { const [popularPage, setPopularPage] = useState(1); const [newReleasesPage, setNewReleasesPage] = useState(1); - const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences(); + const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences(); // Refs for auto-scrolling to section tops const popularSectionRef = useRef(null); @@ -39,6 +38,16 @@ export default function HomePage() { message: newReleasesMessage, } = useAudiobooks('new-releases', 20, newReleasesPage); + // Filter out available titles when hideAvailable is enabled + const filteredPopular = useMemo( + () => hideAvailable ? popular.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : popular, + [popular, hideAvailable] + ); + const filteredNewReleases = useMemo( + () => hideAvailable ? newReleases.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : newReleases, + [newReleases, hideAvailable] + ); + // Handle page changes with auto-scroll to section top const handlePopularPageChange = (page: number) => { setPopularPage(page); @@ -66,10 +75,14 @@ export default function HomePage() {

Popular Audiobooks

-
- - -
+ @@ -87,7 +100,7 @@ export default function HomePage() { ) : ( New Releases -
- - -
+ @@ -128,7 +145,7 @@ export default function HomePage() { ) : ( { const timer = setTimeout(() => { setDebouncedQuery(query); - setPage(1); // Reset to first page on new search }, 500); return () => clearTimeout(timer); }, [query]); - const { results, totalResults, hasMore, isLoading } = useSearch(debouncedQuery, page); + const { results, totalResults, hasMore, isLoading, isLoadingMore, loadMore } = useSearch(debouncedQuery); + + // Filter out available titles when hideAvailable is enabled + const filteredResults = useMemo( + () => hideAvailable ? results.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') : results, + [results, hideAvailable] + ); const handleSearch = useCallback((e: React.FormEvent) => { e.preventDefault(); - setPage(1); }, []); - const handleLoadMore = useCallback(() => { - setPage((prev) => prev + 1); - }, []); + // Header count text: reflects filtered counts + const visibleCount = filteredResults.length; + const countText = hasMore && totalResults > 0 + ? `${visibleCount.toLocaleString()} of ${totalResults.toLocaleString()} result${totalResults !== 1 ? 's' : ''}` + : visibleCount > 0 + ? `${visibleCount.toLocaleString()} result${visibleCount !== 1 ? 's' : ''}` + : ''; return ( @@ -113,45 +120,42 @@ export default function SearchPage() {

Search Results

- {!isLoading && totalResults > 0 && ( + {!isLoading && countText && ( - ({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''}) + ({countText}) )} -
- - -
+ {/* Results Grid */} - {/* Load More */} - {hasMore && !isLoading && ( -
- -
- )} - - {/* Loading More Indicator */} - {isLoading && page > 1 && ( -
-
-
+ {/* Load More Bar */} + {filteredResults.length > 0 && ( + )} ) : ( diff --git a/src/app/series/[asin]/page.tsx b/src/app/series/[asin]/page.tsx index 951824e..6bac34b 100644 --- a/src/app/series/[asin]/page.tsx +++ b/src/app/series/[asin]/page.tsx @@ -5,16 +5,17 @@ 'use client'; -import { use, useCallback } from 'react'; +import { use, useCallback, useMemo } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Header } from '@/components/layout/Header'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; +import { LoadMoreBar } from '@/components/ui/LoadMoreBar'; import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard'; import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow'; import { useSeriesDetail } from '@/lib/hooks/useSeries'; +import { Audiobook } from '@/lib/hooks/useAudiobooks'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; -import { CardSizeControls } from '@/components/ui/CardSizeControls'; -import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle'; +import { SectionToolbar } from '@/components/ui/SectionToolbar'; import { usePreferences } from '@/contexts/PreferencesContext'; export default function SeriesDetailPage({ @@ -26,8 +27,8 @@ export default function SeriesDetailPage({ const router = useRouter(); const searchParams = useSearchParams(); const fromSeriesTitle = searchParams.get('from'); - const { series, isLoading: seriesLoading } = useSeriesDetail(asin); - const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences(); + const { series, hasMore, isLoading: seriesLoading, isLoadingMore, loadMore } = useSeriesDetail(asin); + const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences(); const handleBack = useCallback(() => { // Use browser back if we came from within the app, otherwise fallback to /series @@ -38,6 +39,24 @@ export default function SeriesDetailPage({ } }, [router]); + // Filter out available titles when hideAvailable is enabled + const filteredBooks = useMemo( + () => series && hideAvailable + ? series.books.filter((b: Audiobook) => !b.isAvailable && b.requestStatus !== 'completed') + : series?.books ?? [], + [series, hideAvailable] + ); + + // Header count text: reflects filtered counts + const visibleCount = filteredBooks.length; + const booksCountText = series + ? hasMore && series.bookCount > series.books.length + ? `${visibleCount.toLocaleString()} of ${series.bookCount.toLocaleString()} title${series.bookCount !== 1 ? 's' : ''}` + : visibleCount > 0 + ? `${visibleCount.toLocaleString()} title${visibleCount !== 1 ? 's' : ''}` + : '' + : ''; + return (
@@ -87,27 +106,42 @@ export default function SeriesDetailPage({

Books in Series

- {series.books.length > 0 && ( + {booksCountText && ( - ({series.books.length} title{series.books.length !== 1 ? 's' : ''}) + ({booksCountText}) )} -
- - -
+
{/* Books Grid */} + + {/* Load More Bar */} + {filteredBooks.length > 0 && ( + 0 ? series.bookCount : undefined} + hasMore={hasMore} + isLoading={isLoadingMore} + onLoadMore={loadMore} + /> + )} )} diff --git a/src/components/audiobooks/ManualImportBrowser.tsx b/src/components/audiobooks/ManualImportBrowser.tsx index 4620c4c..9d39347 100644 --- a/src/components/audiobooks/ManualImportBrowser.tsx +++ b/src/components/audiobooks/ManualImportBrowser.tsx @@ -59,6 +59,9 @@ export function ManualImportBrowser({ const [isImporting, setIsImporting] = useState(false); const [importError, setImportError] = useState(null); + // Cleanup source toggle + const [cleanupSource, setCleanupSource] = useState(false); + // Hover state for folder icon swap const [hoveredFolder, setHoveredFolder] = useState(null); @@ -188,6 +191,7 @@ export function ManualImportBrowser({ body: JSON.stringify({ asin: audiobook.asin, folderPath: selectedPath, + cleanupSource, }), }); const data = await res.json(); @@ -288,6 +292,8 @@ export function ManualImportBrowser({ isImporting={isImporting} importError={importError} slideClass={slideClass} + cleanupSource={cleanupSource} + onCleanupSourceChange={setCleanupSource} onBack={handleBackToBrowse} onStartImport={handleStartImport} /> diff --git a/src/components/audiobooks/manual-import/ConfirmPhase.tsx b/src/components/audiobooks/manual-import/ConfirmPhase.tsx index 7860100..c5c80b5 100644 --- a/src/components/audiobooks/manual-import/ConfirmPhase.tsx +++ b/src/components/audiobooks/manual-import/ConfirmPhase.tsx @@ -22,6 +22,8 @@ interface ConfirmPhaseProps { isImporting: boolean; importError: string | null; slideClass: string; + cleanupSource: boolean; + onCleanupSourceChange: (value: boolean) => void; onBack: () => void; onStartImport: () => void; } @@ -35,6 +37,8 @@ export function ConfirmPhase({ isImporting, importError, slideClass, + cleanupSource, + onCleanupSourceChange, onBack, onStartImport, }: ConfirmPhaseProps) { @@ -99,6 +103,30 @@ export function ConfirmPhase({ ))} + + {/* Cleanup source toggle */} +
+
+
+

+ Cleanup source files +

+

+ Delete original files after successful import +

+
+ +
+
{/* Error display */} diff --git a/src/components/requests/InteractiveTorrentSearchModal.tsx b/src/components/requests/InteractiveTorrentSearchModal.tsx index 5d8747c..cd8ce50 100644 --- a/src/components/requests/InteractiveTorrentSearchModal.tsx +++ b/src/components/requests/InteractiveTorrentSearchModal.tsx @@ -34,6 +34,7 @@ interface InteractiveTorrentSearchModalProps { title: string; author: string; }; + customSearchTerms?: string | null; // Optional - admin-set custom search terms override fullAudiobook?: Audiobook; // Optional - only provided when called from details modal onSuccess?: () => void; searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook @@ -87,6 +88,7 @@ export function InteractiveTorrentSearchModal({ requestId, asin, audiobook, + customSearchTerms, fullAudiobook, onSuccess, searchMode = 'audiobook', @@ -114,7 +116,7 @@ export function InteractiveTorrentSearchModal({ const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]); const [confirmTorrent, setConfirmTorrent] = useState(null); - const [searchTitle, setSearchTitle] = useState(audiobook.title); + const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title); const [isCustomConfirming, setIsCustomConfirming] = useState(false); const [mounted, setMounted] = useState(false); @@ -153,9 +155,9 @@ export function InteractiveTorrentSearchModal({ // Reset search title when modal opens/closes or audiobook changes useEffect(() => { - setSearchTitle(audiobook.title); + setSearchTitle(customSearchTerms || audiobook.title); setResults([]); - }, [isOpen, audiobook.title]); + }, [isOpen, audiobook.title, customSearchTerms]); // Perform search when modal opens useEffect(() => { diff --git a/src/components/ui/HideAvailableToggle.tsx b/src/components/ui/HideAvailableToggle.tsx new file mode 100644 index 0000000..38c1bf2 --- /dev/null +++ b/src/components/ui/HideAvailableToggle.tsx @@ -0,0 +1,81 @@ +/** + * Component: Hide Available Toggle + * Documentation: UI toggle for hiding titles already in the user's library + */ + +'use client'; + +import React from 'react'; + +interface HideAvailableToggleProps { + enabled: boolean; + onToggle: (enabled: boolean) => void; +} + +export function HideAvailableToggle({ enabled, onToggle }: HideAvailableToggleProps) { + return ( + + ); +} diff --git a/src/components/ui/LoadMoreBar.tsx b/src/components/ui/LoadMoreBar.tsx new file mode 100644 index 0000000..4e42f87 --- /dev/null +++ b/src/components/ui/LoadMoreBar.tsx @@ -0,0 +1,82 @@ +/** + * Component: LoadMoreBar + * Documentation: documentation/frontend/components.md + */ + +'use client'; + +import { CheckCircleIcon } from '@heroicons/react/24/outline'; + +interface LoadMoreBarProps { + loadedCount: number; + totalCount?: number; + hasMore: boolean; + isLoading: boolean; + onLoadMore: () => void; + itemLabel?: string; +} + +export function LoadMoreBar({ + loadedCount, + totalCount, + hasMore, + isLoading, + onLoadMore, + itemLabel = 'books', +}: LoadMoreBarProps) { + if (loadedCount === 0) return null; + + const allLoaded = !hasMore && !isLoading; + + // Count text + let countText: string; + if (allLoaded) { + countText = `All ${loadedCount.toLocaleString()} ${itemLabel} loaded`; + } else if (totalCount && totalCount > loadedCount) { + countText = `Showing ${loadedCount.toLocaleString()} of ${totalCount.toLocaleString()} ${itemLabel}`; + } else { + countText = `${loadedCount.toLocaleString()} ${itemLabel} loaded`; + } + + return ( +
+
+ {/* Left: Count */} + + {countText} + + + {/* Right: Action */} + {allLoaded ? ( + + + Complete + + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/ui/SectionToolbar.tsx b/src/components/ui/SectionToolbar.tsx new file mode 100644 index 0000000..feffd82 --- /dev/null +++ b/src/components/ui/SectionToolbar.tsx @@ -0,0 +1,175 @@ +/** + * Component: Section Toolbar + * Documentation: Responsive toolbar that shows inline controls on sm+ and collapses to popover on mobile + */ + +'use client'; + +import React, { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; +import { HideAvailableToggle } from '@/components/ui/HideAvailableToggle'; +import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle'; +import { CardSizeControls } from '@/components/ui/CardSizeControls'; + +interface SectionToolbarProps { + hideAvailable: boolean; + onToggleHideAvailable: (v: boolean) => void; + squareCovers: boolean; + onToggleSquareCovers: (v: boolean) => void; + cardSize: number; + onCardSizeChange: (v: number) => void; +} + +export function SectionToolbar({ + hideAvailable, + onToggleHideAvailable, + squareCovers, + onToggleSquareCovers, + cardSize, + onCardSizeChange, +}: SectionToolbarProps) { + const [isOpen, setIsOpen] = useState(false); + const { containerRef, dropdownRef, style } = useSmartDropdownPosition(isOpen); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsOpen(false); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); + + // Close on click outside + useEffect(() => { + if (!isOpen) return; + const handleMouseDown = (e: MouseEvent) => { + const target = e.target as Node; + if ( + containerRef.current && !containerRef.current.contains(target) && + dropdownRef.current && !dropdownRef.current.contains(target) + ) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleMouseDown); + return () => document.removeEventListener('mousedown', handleMouseDown); + }, [isOpen, containerRef, dropdownRef]); + + return ( +
+ {/* Inline controls — visible at sm and above */} +
+ + + +
+ + {/* Collapsed ellipsis trigger — visible below sm */} +
+ + + {/* Portal dropdown */} + {isOpen && typeof document !== 'undefined' && style && createPortal( +
+ {/* Hide Available */} + + + {/* Square Covers */} + + + {/* Divider */} +
+ + {/* Card Size */} +
+ + + + + + Card Size +
+ +
+
+
, + document.body + )} +
+
+ ); +} diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx index 0a8a5b4..be795d7 100644 --- a/src/contexts/PreferencesContext.tsx +++ b/src/contexts/PreferencesContext.tsx @@ -10,6 +10,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from interface Preferences { cardSize: number; // 1-9, default 5 squareCovers: boolean; // true = square (1:1), false = rectangle (2:3) + hideAvailable: boolean; // true = hide "In Your Library" titles } interface PreferencesContextType { @@ -17,6 +18,8 @@ interface PreferencesContextType { setCardSize: (size: number) => void; squareCovers: boolean; setSquareCovers: (enabled: boolean) => void; + hideAvailable: boolean; + setHideAvailable: (enabled: boolean) => void; } const PreferencesContext = createContext(undefined); @@ -24,6 +27,7 @@ const PreferencesContext = createContext(und const DEFAULT_PREFERENCES: Preferences = { cardSize: 5, squareCovers: true, + hideAvailable: false, }; const STORAGE_KEY = 'preferences'; @@ -31,6 +35,7 @@ const STORAGE_KEY = 'preferences'; export function PreferencesProvider({ children }: { children: ReactNode }) { const [cardSize, setCardSizeState] = useState(DEFAULT_PREFERENCES.cardSize); const [squareCovers, setSquareCoversState] = useState(DEFAULT_PREFERENCES.squareCovers); + const [hideAvailable, setHideAvailableState] = useState(DEFAULT_PREFERENCES.hideAvailable); // Load preferences from localStorage on mount useEffect(() => { @@ -49,11 +54,14 @@ export function PreferencesProvider({ children }: { children: ReactNode }) { } // Load squareCovers preference (defaults to false if not set) setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers); + // Load hideAvailable preference + setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable); } } catch (error) { console.error('Failed to load preferences from localStorage:', error); setCardSizeState(DEFAULT_PREFERENCES.cardSize); setSquareCoversState(DEFAULT_PREFERENCES.squareCovers); + setHideAvailableState(DEFAULT_PREFERENCES.hideAvailable); } }, []); @@ -92,6 +100,22 @@ export function PreferencesProvider({ children }: { children: ReactNode }) { } }; + // Update hideAvailable preference in state and localStorage + const setHideAvailable = (enabled: boolean) => { + if (typeof window === 'undefined') return; + + setHideAvailableState(enabled); + + try { + const stored = localStorage.getItem(STORAGE_KEY); + const preferences: Preferences = stored ? JSON.parse(stored) : { ...DEFAULT_PREFERENCES }; + preferences.hideAvailable = enabled; + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + } catch (error) { + console.error('Failed to save preferences to localStorage:', error); + } + }; + // Listen for storage changes in other tabs (cross-tab sync) useEffect(() => { if (typeof window === 'undefined') return; @@ -106,6 +130,8 @@ export function PreferencesProvider({ children }: { children: ReactNode }) { } // Sync squareCovers preference setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers); + // Sync hideAvailable preference + setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable); } catch (error) { console.error('Failed to parse preferences from storage event:', error); } @@ -119,7 +145,7 @@ export function PreferencesProvider({ children }: { children: ReactNode }) { }, []); return ( - + {children} ); diff --git a/src/lib/hooks/useAudiobooks.ts b/src/lib/hooks/useAudiobooks.ts index fa2b8cf..8018ab4 100644 --- a/src/lib/hooks/useAudiobooks.ts +++ b/src/lib/hooks/useAudiobooks.ts @@ -5,7 +5,9 @@ 'use client'; +import { useRef, useEffect, useCallback } from 'react'; import useSWR from 'swr'; +import useSWRInfinite from 'swr/infinite'; import { authenticatedFetcher } from '@/lib/utils/api'; export interface Audiobook { @@ -57,20 +59,58 @@ export function useAudiobooks(type: 'popular' | 'new-releases', limit: number = }; } -export function useSearch(query: string, page: number = 1) { - const shouldFetch = query && query.length > 0; - const endpoint = shouldFetch ? `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${page}` : null; - - const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { - revalidateOnFocus: false, - dedupingInterval: 30000, // Cache for 30 seconds +function dedupeByAsin(items: T[]): T[] { + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.asin)) return false; + seen.add(item.asin); + return true; }); +} + +export function useSearch(query: string) { + const prevQueryRef = useRef(query); + + const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite( + (pageIndex, prevPageData) => { + if (!query || query.length === 0) return null; + if (pageIndex === 0) return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=1`; + if (!prevPageData?.hasMore) return null; + return `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${pageIndex + 1}`; + }, + authenticatedFetcher, + { + revalidateOnFocus: false, + dedupingInterval: 30000, + revalidateFirstPage: false, + } + ); + + // Reset to page 1 when query changes + useEffect(() => { + if (query !== prevQueryRef.current) { + prevQueryRef.current = query; + setSize(1); + } + }, [query, setSize]); + + const results = data ? dedupeByAsin(data.flatMap(page => page?.results || [])) : []; + const totalResults = data?.[0]?.totalResults || 0; + const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore); + const isLoadingInitial = !data && !error && !!query; + const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating); + + const loadMore = useCallback(() => { + setSize(prev => prev + 1); + }, [setSize]); return { - results: data?.results || [], - totalResults: data?.totalResults || 0, - hasMore: data?.hasMore || false, - isLoading: shouldFetch && isLoading, + results, + totalResults, + hasMore, + isLoading: isLoadingInitial, + isLoadingMore, + loadMore, error, }; } diff --git a/src/lib/hooks/useAuthors.ts b/src/lib/hooks/useAuthors.ts index d7eff18..bd38d6e 100644 --- a/src/lib/hooks/useAuthors.ts +++ b/src/lib/hooks/useAuthors.ts @@ -5,7 +5,9 @@ 'use client'; +import { useRef, useEffect, useCallback } from 'react'; import useSWR from 'swr'; +import useSWRInfinite from 'swr/infinite'; import { authenticatedFetcher } from '@/lib/utils/api'; import { Audiobook } from './useAudiobooks'; @@ -68,21 +70,59 @@ export function useAuthorDetail(asin: string | null) { }; } -export function useAuthorBooks(asin: string | null, authorName: string | null) { - const shouldFetch = asin && authorName; - const endpoint = shouldFetch - ? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}` - : null; - - const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { - revalidateOnFocus: false, - dedupingInterval: 60000, // Cache for 1 minute +function dedupeByAsin(items: T[]): T[] { + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.asin)) return false; + seen.add(item.asin); + return true; }); +} + +export function useAuthorBooks(asin: string | null, authorName: string | null) { + const prevIdentityRef = useRef(null); + const identity = asin && authorName ? `${asin}:${authorName}` : null; + + const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite( + (pageIndex, prevPageData) => { + if (!asin || !authorName) return null; + if (pageIndex === 0) return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=1`; + if (!prevPageData?.hasMore) return null; + return `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}&page=${pageIndex + 1}`; + }, + authenticatedFetcher, + { + revalidateOnFocus: false, + dedupingInterval: 60000, + revalidateFirstPage: false, + } + ); + + // Reset when author changes + useEffect(() => { + if (identity !== prevIdentityRef.current) { + prevIdentityRef.current = identity; + setSize(1); + } + }, [identity, setSize]); + + const books = (data ? dedupeByAsin(data.flatMap(page => page?.books || [])) : []) as Audiobook[]; + const totalBooks = data?.[0]?.totalBooks || 0; + const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore); + const isLoadingInitial = !data && !error && !!identity; + const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating); + + const loadMore = useCallback(() => { + setSize(prev => prev + 1); + }, [setSize]); return { - books: (data?.books || []) as Audiobook[], - totalBooks: data?.totalBooks || 0, - isLoading: !!shouldFetch && isLoading, + books, + totalBooks, + hasMore, + isLoading: isLoadingInitial || (!!identity && isLoading), + isLoadingMore, + loadMore, error, }; } diff --git a/src/lib/hooks/useSeries.ts b/src/lib/hooks/useSeries.ts index b8660f2..35c4707 100644 --- a/src/lib/hooks/useSeries.ts +++ b/src/lib/hooks/useSeries.ts @@ -5,7 +5,9 @@ 'use client'; +import { useRef, useEffect, useCallback } from 'react'; import useSWR from 'swr'; +import useSWRInfinite from 'swr/infinite'; import { authenticatedFetcher } from '@/lib/utils/api'; import { Audiobook } from './useAudiobooks'; @@ -59,17 +61,63 @@ export function useSeriesSearch(query: string) { }; } -export function useSeriesDetail(asin: string | null) { - const endpoint = asin ? `/api/series/${asin}` : null; - - const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { - revalidateOnFocus: false, - dedupingInterval: 300000, // Cache for 5 minutes +function dedupeByAsin(items: T[]): T[] { + const seen = new Set(); + return items.filter(item => { + if (seen.has(item.asin)) return false; + seen.add(item.asin); + return true; }); +} + +export function useSeriesDetail(asin: string | null) { + const prevAsinRef = useRef(null); + + const { data, error, size, setSize, isLoading, isValidating } = useSWRInfinite( + (pageIndex, prevPageData) => { + if (!asin) return null; + if (pageIndex === 0) return `/api/series/${asin}?page=1`; + if (!prevPageData?.hasMore) return null; + return `/api/series/${asin}?page=${pageIndex + 1}`; + }, + authenticatedFetcher, + { + revalidateOnFocus: false, + dedupingInterval: 300000, + revalidateFirstPage: false, + } + ); + + // Reset when series changes + useEffect(() => { + if (asin !== prevAsinRef.current) { + prevAsinRef.current = asin; + setSize(1); + } + }, [asin, setSize]); + + // Merge pages: use first page's metadata, accumulate all books + const firstPageSeries = data?.[0]?.series as SeriesDetail | undefined; + const allBooks = (data ? dedupeByAsin(data.flatMap(page => page?.series?.books || [])) : []) as Audiobook[]; + + const series: SeriesDetail | null = firstPageSeries + ? { ...firstPageSeries, books: allBooks } + : null; + + const hasMore = !!(data && data.length > 0 && data[data.length - 1]?.hasMore); + const isLoadingInitial = !data && !error && !!asin; + const isLoadingMore = !!(data && typeof data[size - 1] === 'undefined' && isValidating); + + const loadMore = useCallback(() => { + setSize(prev => prev + 1); + }, [setSize]); return { - series: (data?.series || null) as SeriesDetail | null, - isLoading, + series, + hasMore, + isLoading: isLoadingInitial || (!!asin && isLoading), + isLoadingMore, + loadMore, error, }; } diff --git a/src/lib/integrations/audible-series.ts b/src/lib/integrations/audible-series.ts index 717b33d..f5df693 100644 --- a/src/lib/integrations/audible-series.ts +++ b/src/lib/integrations/audible-series.ts @@ -288,17 +288,17 @@ function parseSeriesPageSummary( * Scrape a series page for full detail data including books and similar series. * Used by the detail API endpoint. */ -export async function scrapeSeriesPage(asin: string): Promise { +export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<(SeriesDetail & { hasMore: boolean; page: number }) | null> { const service = getAudibleService(); const region = service.getRegion(); const baseUrl = service.getBaseUrl(); const langConfig = getLanguageForRegion(region); - logger.info(`Scraping series detail page: ${asin}`); + logger.info(`Scraping series detail page: ${asin}, page ${page}`); try { const { data: response } = await service.fetch(`/series/${asin}`, { - params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE }, + params: { ipRedirectOverride: 'true', pageSize: AUDIBLE_PAGE_SIZE, page }, }); const $ = cheerio.load(response.data); @@ -316,10 +316,15 @@ export async function scrapeSeriesPage(asin: string): Promise 0 + ? page * AUDIBLE_PAGE_SIZE < bookCount + : books.length >= AUDIBLE_PAGE_SIZE; + // Parse similar series ("Listeners also enjoyed" or similar section) const similarSeries = parseSimilarSeries($); - logger.info(`Series detail complete: "${summary.title}" (${books.length} books, ${similarSeries.length} similar)`); + logger.info(`Series detail complete: "${summary.title}" (${books.length} books, page ${page}, hasMore: ${hasMore})`); return { asin, @@ -332,6 +337,8 @@ export async function scrapeSeriesPage(asin: string): Promise 0 && totalResults > page * AUDIBLE_PAGE_SIZE, + hasMore: audiobooks.length > 0 && (totalResults > 0 + ? totalResults > page * AUDIBLE_PAGE_SIZE + : audiobooks.length >= AUDIBLE_PAGE_SIZE), }; } catch (error) { logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) }); @@ -583,123 +592,111 @@ export class AudibleService { * Uses Audible's searchAuthor parameter and paginates through all results. * Filters: (1) author link must contain the target ASIN, (2) language must be English. */ - async searchByAuthorAsin(authorName: string, authorAsin: string): Promise { + async searchByAuthorAsin(authorName: string, authorAsin: string, page: number = 1): Promise { await this.initialize(); - const MAX_PAGES = 10; - const allBooks: AudibleAudiobook[] = []; + const books: AudibleAudiobook[] = []; const seenAsins = new Set(); try { - logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin})...`); + logger.info(`Searching books by author "${authorName}" (ASIN: ${authorAsin}), page ${page}...`); - for (let page = 1; page <= MAX_PAGES; page++) { - const { data: response, meta } = await this.fetchWithRetry('/search', { - params: { - ipRedirectOverride: 'true', - searchAuthor: authorName, - pageSize: AUDIBLE_PAGE_SIZE, - page, - }, + const { data: response } = await this.fetchWithRetry('/search', { + params: { + ipRedirectOverride: 'true', + searchAuthor: authorName, + pageSize: AUDIBLE_PAGE_SIZE, + page, + }, + }); + + const $ = cheerio.load(response.data); + + // Count raw items on page before filtering (for hasMore fallback) + const pageItemCount = $('.s-result-item, .productListItem').length; + + $('.s-result-item, .productListItem').each((_index, element) => { + const $el = $(element); + + // --- Language filter: require matching language for region --- + const langConfig = this.getLangConfig(); + const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() || + $el.find('.languageLabel').text().trim(); + const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i'); + const langMatch = langText.match(langLabelPattern); + const language = langMatch?.[1]?.trim(); + if (!language || !isAcceptedLanguage(language, langConfig)) return; + + // --- Author ASIN filter: verify target ASIN in author links --- + const authorLinks = $el.find('a[href*="/author/"]'); + let hasMatchingAuthor = false; + authorLinks.each((_i, link) => { + const href = $(link).attr('href') || ''; + const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); + if (asinMatch && asinMatch[1] === authorAsin) { + hasMatchingAuthor = true; + return false; // break .each() + } }); + if (!hasMatchingAuthor) return; - const $ = cheerio.load(response.data); - let pageResults = 0; + // --- Extract book ASIN --- + const bookAsin = $el.find('li').attr('data-asin') || + $el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || + $el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || + $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || ''; + if (!bookAsin || seenAsins.has(bookAsin)) return; + seenAsins.add(bookAsin); - $('.s-result-item, .productListItem').each((_index, element) => { - const $el = $(element); + // --- Parse book details --- + const title = $el.find('h2').first().text().trim() || + $el.find('h3 a').text().trim() || + $el.find('.bc-heading a').text().trim(); - // --- Language filter: require matching language for region --- - const langConfig = this.getLangConfig(); - const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() || - $el.find('.languageLabel').text().trim(); - // Extract language value (e.g. "Language: English" -> "English", "Sprache: Deutsch" -> "Deutsch") - const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i'); - const langMatch = langText.match(langLabelPattern); - const language = langMatch?.[1]?.trim(); - if (!language || !isAcceptedLanguage(language, langConfig)) return; + const authorText = $el.find('a[href*="/author/"]').first().text().trim() || + $el.find('.authorLabel').text().trim() || + $el.find('.bc-size-small .bc-text-bold').first().text().trim(); - // --- Author ASIN filter: verify target ASIN in author links --- - const authorLinks = $el.find('a[href*="/author/"]'); - let hasMatchingAuthor = false; - authorLinks.each((_i, link) => { - const href = $(link).attr('href') || ''; - const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/); - if (asinMatch && asinMatch[1] === authorAsin) { - hasMatchingAuthor = true; - return false; // break .each() - } - }); - if (!hasMatchingAuthor) return; + const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() || + $el.find('.narratorLabel').text().trim(); - // --- Extract book ASIN --- - const bookAsin = $el.find('li').attr('data-asin') || - $el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || - $el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || - $el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || ''; - if (!bookAsin || seenAsins.has(bookAsin)) return; - seenAsins.add(bookAsin); + const coverArtUrl = $el.find('img').attr('src') || ''; - // --- Parse book details --- - const title = $el.find('h2').first().text().trim() || - $el.find('h3 a').text().trim() || - $el.find('.bc-heading a').text().trim(); + const runtimeText = $el.find('.runtimeLabel').text().trim() || + $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim(); + const durationMinutes = this.parseRuntime(runtimeText); - const authorText = $el.find('a[href*="/author/"]').first().text().trim() || - $el.find('.authorLabel').text().trim() || - $el.find('.bc-size-small .bc-text-bold').first().text().trim(); + const ratingText = $el.find('.ratingsLabel').text().trim() || + $el.find('.a-icon-star span').first().text().trim(); + const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; - const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() || - $el.find('.narratorLabel').text().trim(); - - const coverArtUrl = $el.find('img').attr('src') || ''; - - const runtimeText = $el.find('.runtimeLabel').text().trim() || - $el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim(); - const durationMinutes = this.parseRuntime(runtimeText); - - const ratingText = $el.find('.ratingsLabel').text().trim() || - $el.find('.a-icon-star span').first().text().trim(); - const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; - - allBooks.push({ - asin: bookAsin, - title, - author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), - authorAsin, - narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes), - coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), - durationMinutes, - rating, - }); - - pageResults++; + books.push({ + asin: bookAsin, + title, + author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), + authorAsin, + narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes), + coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'), + durationMinutes, + rating, }); + }); - // Check if there are more pages - const resultsText = $('.resultsInfo').text().trim(); - const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0'); - const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE; + // Check total results for pagination + const resultsText = $('.resultsInfo').text().trim(); + const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0'); + // Use totalResults if available; otherwise fall back to whether Audible returned a full page + const hasMore = books.length > 0 && (totalResults > 0 + ? totalResults > page * AUDIBLE_PAGE_SIZE + : pageItemCount >= AUDIBLE_PAGE_SIZE); - logger.info(`Author books page ${page}: ${pageResults} valid results (${allBooks.length} total, ${totalResults} Audible total)`); - - if (!hasMore || pageResults === 0) break; - - // Pace between pages - if (page < MAX_PAGES) { - await this.delay(this.pacer.reportPageResult(meta)); - } - } - - logger.info(`Author books search complete: "${authorName}" → ${allBooks.length} books`); - return allBooks; + logger.info(`Author books page ${page}: ${books.length} valid results (${totalResults} Audible total)`); + return { books, hasMore, page, totalResults }; } catch (error) { logger.error(`Author books search failed for "${authorName}"`, { error: error instanceof Error ? error.message : String(error), - collectedSoFar: allBooks.length, }); - // Return what we collected before the error - return allBooks; + return { books, hasMore: false, page, totalResults: 0 }; } } diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts index eb0b5ae..e537d2e 100644 --- a/src/lib/processors/download-torrent.processor.ts +++ b/src/lib/processors/download-torrent.processor.ts @@ -9,6 +9,7 @@ import { getConfigService } from '../services/config.service'; import { getDownloadClientManager } from '../services/download-client-manager.service'; import { ProwlarrService } from '../integrations/prowlarr.service'; import { RMABLogger } from '../utils/logger'; +import { isTransientConnectionError } from '../utils/connection-errors'; /** * Process download job @@ -121,15 +122,22 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P } catch (error) { logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); - // Update request status to failed - await prisma.request.update({ - where: { id: requestId }, - data: { - status: 'failed', - errorMessage: error instanceof Error ? error.message : 'Failed to add download to client', - updatedAt: new Date(), - }, - }); + if (isTransientConnectionError(error)) { + // Connection error — don't mark request as failed yet. + // Bull will retry this job (3 attempts with exponential backoff). + // If all retries are exhausted, the global failed handler marks it failed. + logger.warn(`Download client unreachable for request ${requestId}, allowing Bull to retry`); + } else { + // Permanent error — mark request as failed immediately + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Failed to add download to client', + updatedAt: new Date(), + }, + }); + } throw error; } diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts index 804ba88..96269c1 100644 --- a/src/lib/processors/monitor-download.processor.ts +++ b/src/lib/processors/monitor-download.processor.ts @@ -10,6 +10,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper'; import { getConfigService } from '../services/config.service'; import { getDownloadClientManager } from '../services/download-client-manager.service'; import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; +import { isTransientConnectionError } from '../utils/connection-errors'; /** * Process monitor download job @@ -20,6 +21,12 @@ import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download- const BASE_POLL_INTERVAL = 10; /** Maximum polling interval in seconds (5 minutes) */ const MAX_POLL_INTERVAL = 300; +/** + * Maximum consecutive connection failures before permanently failing the download. + * With exponential backoff (10s base, 300s cap), 30 failures spans roughly 30-45 minutes — + * enough to survive a Docker restart, service update, or transient network outage. + */ +const MAX_CONNECTION_FAILURES = 30; /** * Compute next poll delay with exponential backoff for stalled downloads. @@ -32,7 +39,8 @@ function getBackoffDelay(stallCount: number): number { export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise { const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId, - lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount } = payload; + lastProgress: prevProgress, stallCount: prevStallCount, pathWaitCount: prevPathWaitCount, + connectionFailureCount: prevConnectionFailures } = payload; const logger = RMABLogger.forJob(jobId, 'MonitorDownload'); @@ -288,51 +296,99 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P } catch (error) { logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); - // Check if this is a transient "not found" error const errorMessage = error instanceof Error ? error.message : ''; const isNotFound = errorMessage.includes('not found'); + const isConnectionError = isTransientConnectionError(error); if (isNotFound) { - // Transient error - don't mark request as failed, let Bull retry - // The request stays in 'downloading' status until Bull exhausts all retries + // PATH 1: "Not found" — transient race condition. + // Don't mark request as failed; let Bull retry the same job. logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`); - } else { - // Permanent error - mark request as failed immediately - const failureMessage = errorMessage || 'Monitor download failed'; - await prisma.request.update({ - where: { id: requestId }, - data: { - status: 'failed', - errorMessage: failureMessage, - updatedAt: new Date(), - }, - }); + throw error; + } - // Send notification for request failure - const request = await prisma.request.findUnique({ - where: { id: requestId }, - include: { - audiobook: true, - user: { select: { plexUsername: true } }, - }, - }); + if (isConnectionError) { + // PATH 2: Connection failure — download client is temporarily unreachable. + // Instead of failing the download, self-schedule the next poll with backoff. + // This reuses the same adaptive backoff as stalled downloads, giving the + // client time to recover (restart, network blip, update, etc.). + const failureCount = (prevConnectionFailures ?? 0) + 1; + + if (failureCount >= MAX_CONNECTION_FAILURES) { + // Exhausted patience — treat as permanent failure + logger.error( + `Download client unreachable for ${failureCount} consecutive checks, giving up on request ${requestId}` + ); + // Fall through to permanent failure handling below + } else { + const delay = getBackoffDelay(failureCount); + logger.warn( + `Download client unreachable (${failureCount}/${MAX_CONNECTION_FAILURES}), ` + + `retrying in ${delay}s for request ${requestId}`, + { error: errorMessage } + ); - if (request) { const jobQueue = getJobQueueService(); - await jobQueue.addNotificationJob( - 'request_error', - request.id, - request.audiobook.title, - request.audiobook.author, - request.user.plexUsername || 'Unknown User', - failureMessage - ).catch((error) => { - logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); - }); + await jobQueue.addMonitorJob( + requestId, + downloadHistoryId, + downloadClientId, + downloadClient, + delay, + prevProgress, + prevStallCount ?? 0, + prevPathWaitCount, + failureCount + ); + + // Return success — the monitoring loop continues via the new job. + // Do NOT throw: that would trigger Bull's retry on this job as well. + return { + success: true, + completed: false, + message: `Download client unreachable, will retry in ${delay}s`, + requestId, + connectionFailureCount: failureCount, + }; } } - // Rethrow to trigger Bull's retry mechanism + // PATH 3: Permanent error (or connection failures exhausted). + // Mark request as failed immediately. + const failureMessage = errorMessage || 'Monitor download failed'; + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'failed', + errorMessage: failureMessage, + updatedAt: new Date(), + }, + }); + + // Send notification for request failure + const request = await prisma.request.findUnique({ + where: { id: requestId }, + include: { + audiobook: true, + user: { select: { plexUsername: true } }, + }, + }); + + if (request) { + const jobQueue = getJobQueueService(); + await jobQueue.addNotificationJob( + 'request_error', + request.id, + request.audiobook.title, + request.audiobook.author, + request.user.plexUsername || 'Unknown User', + failureMessage + ).catch((notifError) => { + logger.error('Failed to queue notification', { error: notifError instanceof Error ? notifError.message : String(notifError) }); + }); + } + + // Rethrow to trigger Bull's retry mechanism as a safety net throw error; } } diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index da6157b..3e6252b 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -22,7 +22,7 @@ import { removeEmptyParentDirectories } from '../utils/cleanup-helpers'; * Handles both audiobook and ebook request types with appropriate branching */ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise { - const { requestId, audiobookId, downloadPath, jobId } = payload; + const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload; const logger = RMABLogger.forJob(jobId, 'OrganizeFiles'); @@ -264,6 +264,11 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi // Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup) await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger); + // Cleanup source files if requested (manual import feature) + if (cleanupSource) { + await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger); + } + return { success: true, message: 'Files organized successfully', @@ -467,7 +472,7 @@ async function processEbookOrganization( request: { id: string; userId: string; type: string; user: { plexUsername: string | null } }, logger: RMABLogger ): Promise { - const { requestId, audiobookId, downloadPath, jobId } = payload; + const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload; logger.info(`Processing ebook organization for request ${requestId}`); @@ -726,6 +731,11 @@ async function processEbookOrganization( // Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup) await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger); + // Cleanup source files if requested (manual import feature) + if (cleanupSource) { + await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger); + } + return { success: true, message: 'Ebook organized successfully', @@ -1003,6 +1013,68 @@ async function cleanupDownloadAfterOrganize( } } +// ========================================================================= +// SOURCE FILE CLEANUP (MANUAL IMPORT) +// ========================================================================= + +/** + * Delete source files after successful manual import. + * Non-fatal: logs a warning on failure but does not fail the job. + * Files are already safely copied to the media library at this point. + */ +async function cleanupSourceAfterOrganize( + downloadPath: string, + configService: any, + jobId: string | undefined, + logger: RMABLogger +): Promise { + try { + const fs = await import('fs/promises'); + + logger.info(`Cleaning up source files: ${downloadPath}`); + + const stats = await fs.stat(downloadPath); + if (stats.isDirectory()) { + await fs.rm(downloadPath, { recursive: true, force: true }); + logger.info(`Removed source directory: ${downloadPath}`); + } else { + await fs.unlink(downloadPath); + logger.info(`Removed source file: ${downloadPath}`); + } + + // Determine boundary path based on download path prefix + const BOOKDROP_PATH = '/bookdrop'; + const downloadDir = await configService.get('download_dir') || '/downloads'; + const mediaDir = await configService.get('media_dir') || '/media'; + + let boundaryPath = downloadDir; + if (downloadPath.startsWith(BOOKDROP_PATH)) { + boundaryPath = BOOKDROP_PATH; + } else if (downloadPath.startsWith(mediaDir)) { + boundaryPath = mediaDir; + } + + const cleanupResult = await removeEmptyParentDirectories(downloadPath, { + boundaryPath, + logContext: jobId ? { jobId, context: 'CleanupSourceParents' } : undefined, + }); + + if (cleanupResult.removedDirectories.length > 0) { + logger.info(`Cleaned up ${cleanupResult.removedDirectories.length} empty parent directories`); + } + } catch (error) { + // Non-fatal - files are already safely in the media library + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info(`Source path already deleted: ${downloadPath}`); + } else { + logger.warn( + `Failed to cleanup source files: ${error instanceof Error ? error.message : 'Unknown error'}`, + { error: error instanceof Error ? error.stack : undefined } + ); + } + } +} + // ========================================================================= // HELPER FUNCTIONS // ========================================================================= diff --git a/src/lib/processors/search-indexers.processor.ts b/src/lib/processors/search-indexers.processor.ts index 9157cee..e114023 100644 --- a/src/lib/processors/search-indexers.processor.ts +++ b/src/lib/processors/search-indexers.processor.ts @@ -34,6 +34,13 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro }, }); + // Check for custom search terms override + const requestRecord = await prisma.request.findUnique({ + where: { id: requestId }, + select: { customSearchTerms: true }, + }); + const effectiveSearchTitle = requestRecord?.customSearchTerms || audiobook.title; + // Get enabled indexers from configuration const { getConfigService } = await import('../services/config.service'); const configService = getConfigService(); @@ -77,7 +84,11 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro // Get Prowlarr service const prowlarr = await getProwlarrService(); - logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`); + if (requestRecord?.customSearchTerms) { + logger.info(`Searching with custom terms: "${effectiveSearchTitle}" (original: "${audiobook.title}") by "${audiobook.author}"`); + } else { + logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`); + } // Search Prowlarr for each group and combine results const allResults = []; @@ -87,7 +98,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); try { - const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, { + const groupResults = await prowlarr.searchWithVariations(effectiveSearchTitle, audiobook.author, { categories: group.categories, indexerIds: group.indexerIds, minSeeders: 1, // Only torrents with at least 1 seeder diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 76e8680..6617a63 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -66,6 +66,7 @@ export interface MonitorDownloadPayload extends JobPayload { lastProgress?: number; // Previous poll's progress (0-100) for stall detection stallCount?: number; // Consecutive polls with no progress change (drives backoff) pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path + connectionFailureCount?: number; // Consecutive polls where the download client was unreachable } export interface OrganizeFilesPayload extends JobPayload { @@ -73,6 +74,7 @@ export interface OrganizeFilesPayload extends JobPayload { audiobookId: string; downloadPath: string; targetPath?: string; // Optional - not used by processor (reads from database config) + cleanupSource?: boolean; // If true, delete source files after successful import } export interface ScanPlexPayload extends JobPayload { @@ -259,6 +261,29 @@ export class JobQueueService { logger.error('Failed to update request/download status', { error: updateError instanceof Error ? updateError.message : String(updateError) }); } } + + // Safety net for download_torrent: if the processor skipped marking the + // request as failed (e.g. connection error with Bull retries), ensure the + // request is marked failed after all retries are exhausted. + if (job.name === 'download_torrent' && job.data) { + const payload = job.data as DownloadTorrentPayload; + logger.error(`DownloadTorrent job permanently failed for request ${payload.requestId} after ${job.attemptsMade} attempts`); + + try { + await prisma.request.update({ + where: { id: payload.requestId }, + data: { + status: 'failed', + errorMessage: error.message || 'Failed to add download after multiple retries', + updatedAt: new Date(), + }, + }); + } catch (updateError) { + logger.error('Failed to update request status after download_torrent failure', { + error: updateError instanceof Error ? updateError.message : String(updateError), + }); + } + } }); this.queue.on('stalled', async (job: BullJob) => { @@ -569,7 +594,8 @@ export class JobQueueService { delaySeconds: number = 0, lastProgress?: number, stallCount?: number, - pathWaitCount?: number + pathWaitCount?: number, + connectionFailureCount?: number ): Promise { return await this.addJob( 'monitor_download', @@ -581,6 +607,7 @@ export class JobQueueService { lastProgress, stallCount, pathWaitCount, + connectionFailureCount, } as MonitorDownloadPayload, { priority: 5, // Medium priority @@ -597,7 +624,8 @@ export class JobQueueService { requestId: string, audiobookId: string, downloadPath: string, - targetPath?: string + targetPath?: string, + cleanupSource?: boolean ): Promise { return await this.addJob( 'organize_files', @@ -606,6 +634,7 @@ export class JobQueueService { audiobookId, downloadPath, targetPath, // Not used by processor + cleanupSource, } as OrganizeFilesPayload, { priority: 8, diff --git a/src/lib/services/notification/providers/apprise.provider.ts b/src/lib/services/notification/providers/apprise.provider.ts index 9c290c7..ebafce9 100644 --- a/src/lib/services/notification/providers/apprise.provider.ts +++ b/src/lib/services/notification/providers/apprise.provider.ts @@ -45,13 +45,31 @@ export class AppriseProvider implements INotificationProvider { const meta = getEventMeta(payload.event); const { title, body } = this.formatMessage(payload); - const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, ''); - const notificationType = SEVERITY_TYPES[meta.severity]; - + // Parse URL to extract embedded HTTP Basic Auth credentials (e.g. https://user:pass@host/) + let serverUrl: string; const headers: Record = { 'Content-Type': 'application/json', }; + try { + const parsed = new URL(appriseConfig.serverUrl); + if (parsed.username) { + const username = decodeURIComponent(parsed.username); + const password = decodeURIComponent(parsed.password); + headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + parsed.username = ''; + parsed.password = ''; + serverUrl = parsed.toString().replace(/\/+$/, ''); + } else { + serverUrl = appriseConfig.serverUrl.replace(/\/+$/, ''); + } + } catch { + serverUrl = appriseConfig.serverUrl.replace(/\/+$/, ''); + } + + const notificationType = SEVERITY_TYPES[meta.severity]; + + // Explicit authToken (Bearer) takes precedence over URL-embedded credentials if (appriseConfig.authToken) { headers['Authorization'] = `Bearer ${appriseConfig.authToken}`; } diff --git a/src/lib/utils/connection-errors.ts b/src/lib/utils/connection-errors.ts new file mode 100644 index 0000000..f690a9c --- /dev/null +++ b/src/lib/utils/connection-errors.ts @@ -0,0 +1,80 @@ +/** + * Component: Connection Error Classification Utility + * Documentation: documentation/phase3/README.md + * + * Classifies errors as transient connection failures (e.g. download client + * restarting, network blip) vs permanent failures. Used by download + * processors to decide whether to retry with backoff or fail immediately. + */ + +/** Node/Axios error codes that indicate the remote service is temporarily unreachable. */ +const TRANSIENT_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ECONNABORTED', + 'ETIMEDOUT', + 'ENOTFOUND', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'EPIPE', + 'EAI_AGAIN', +]); + +/** HTTP status codes that indicate a gateway / upstream service issue. */ +const TRANSIENT_HTTP_STATUSES = new Set([502, 503, 504]); + +/** + * Substrings in error messages that strongly indicate a connection-level + * failure. Checked as a fallback when structured error properties are + * unavailable (e.g. errors re-thrown as plain Error with a message string). + */ +const TRANSIENT_MESSAGE_PATTERNS = [ + 'ECONNREFUSED', + 'ECONNRESET', + 'ECONNABORTED', + 'ETIMEDOUT', + 'ENOTFOUND', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'EPIPE', + 'EAI_AGAIN', + 'connect ECONNREFUSED', + 'socket hang up', + 'network error', + 'Client network socket disconnected', +] as const; + +/** + * Returns `true` when the error looks like a transient connection failure + * rather than a permanent / logical error. + * + * Checks (in order): + * 1. `error.code` — Node.js / Axios error codes + * 2. `error.response.status` — HTTP gateway errors (502/503/504) + * 3. `error.message` — fallback substring matching + */ +export function isTransientConnectionError(error: unknown): boolean { + if (!error) return false; + + // 1. Structured error code (Node.js / Axios) + const code = (error as any)?.code; + if (typeof code === 'string' && TRANSIENT_ERROR_CODES.has(code)) { + return true; + } + + // 2. HTTP gateway status from Axios response + const status = (error as any)?.response?.status; + if (typeof status === 'number' && TRANSIENT_HTTP_STATUSES.has(status)) { + return true; + } + + // 3. Fallback: substring match on the error message + const message = (error instanceof Error ? error.message : String(error)).toUpperCase(); + for (const pattern of TRANSIENT_MESSAGE_PATTERNS) { + if (message.includes(pattern.toUpperCase())) { + return true; + } + } + + return false; +} diff --git a/tests/app/admin/components/RequestActionsDropdown.test.tsx b/tests/app/admin/components/RequestActionsDropdown.test.tsx index 88bf9ae..8104a90 100644 --- a/tests/app/admin/components/RequestActionsDropdown.test.tsx +++ b/tests/app/admin/components/RequestActionsDropdown.test.tsx @@ -29,6 +29,10 @@ vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({ }) => (isOpen ?
Interactive search for {audiobook.title}
: null), })); +vi.mock('@/app/admin/components/AdjustSearchTermsModal', () => ({ + AdjustSearchTermsModal: () => null, +})); + describe('RequestActionsDropdown', () => { it('exposes manual search, interactive search, cancel, and delete actions', async () => { const onManualSearch = vi.fn().mockResolvedValue(undefined); diff --git a/tests/app/search.page.test.tsx b/tests/app/search.page.test.tsx index c89d991..a00b0c2 100644 --- a/tests/app/search.page.test.tsx +++ b/tests/app/search.page.test.tsx @@ -11,14 +11,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resetMockAuthState } from '../helpers/mock-auth'; import { resetMockRouter } from '../helpers/mock-next-navigation'; +const loadMoreMock = vi.hoisted(() => vi.fn()); const useSearchMock = vi.hoisted(() => vi.fn()); const usePreferencesMock = vi.hoisted(() => ({ cardSize: 5, setCardSize: vi.fn(), + squareCovers: false, + setSquareCovers: vi.fn(), + hideAvailable: false, + setHideAvailable: vi.fn(), })); vi.mock('@/lib/hooks/useAudiobooks', () => ({ useSearch: useSearchMock, + Audiobook: {}, })); vi.mock('@/contexts/PreferencesContext', () => ({ @@ -49,8 +55,30 @@ vi.mock('@/components/audiobooks/AudiobookGrid', () => ({ ), })); -vi.mock('@/components/ui/CardSizeControls', () => ({ - CardSizeControls: ({ size }: { size: number }) =>
, +vi.mock('@/components/ui/SectionToolbar', () => ({ + SectionToolbar: () =>
, +})); + +vi.mock('@/components/ui/LoadMoreBar', () => ({ + LoadMoreBar: ({ + hasMore, + isLoading, + onLoadMore, + }: { + loadedCount: number; + totalCount?: number; + hasMore: boolean; + isLoading: boolean; + onLoadMore: () => void; + itemLabel?: string; + }) => + hasMore ? ( + + ) : ( +
All loaded
+ ), })); describe('SearchPage', () => { @@ -58,6 +86,7 @@ describe('SearchPage', () => { resetMockAuthState(); resetMockRouter(); useSearchMock.mockReset(); + loadMoreMock.mockReset(); usePreferencesMock.cardSize = 5; usePreferencesMock.setCardSize.mockReset(); vi.useFakeTimers(); @@ -74,34 +103,25 @@ describe('SearchPage', () => { totalResults: 0, hasMore: false, isLoading: false, + isLoadingMore: false, + loadMore: loadMoreMock, }); const { default: SearchPage } = await import('@/app/search/page'); render(); expect(screen.getByText('Start typing to search for audiobooks')).toBeInTheDocument(); - expect(useSearchMock).toHaveBeenCalledWith('', 1); + expect(useSearchMock).toHaveBeenCalledWith(''); }); it('debounces search input and loads more results', async () => { - useSearchMock.mockImplementation((query: string, page: number) => { - if (!query) { - return { results: [], totalResults: 0, hasMore: false, isLoading: false }; - } - if (page === 1) { - return { - results: [{ asin: 'a1', title: 'Book One', author: 'Author' }], - totalResults: 2, - hasMore: true, - isLoading: false, - }; - } - return { - results: [{ asin: 'a2', title: 'Book Two', author: 'Author' }], - totalResults: 2, - hasMore: false, - isLoading: false, - }; + useSearchMock.mockReturnValue({ + results: [{ asin: 'a1', title: 'Book One', author: 'Author' }], + totalResults: 2, + hasMore: true, + isLoading: false, + isLoadingMore: false, + loadMore: loadMoreMock, }); const { default: SearchPage } = await import('@/app/search/page'); @@ -115,11 +135,11 @@ describe('SearchPage', () => { }); expect(screen.getByText('Search Results')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Load More Results' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Load more' })).toBeInTheDocument(); expect(screen.getByTestId('grid')).toHaveAttribute('data-count', '1'); - fireEvent.click(screen.getByRole('button', { name: 'Load More Results' })); + fireEvent.click(screen.getByRole('button', { name: 'Load more' })); - expect(useSearchMock).toHaveBeenCalledWith('Dune', 2); + expect(loadMoreMock).toHaveBeenCalled(); }); }); diff --git a/tests/lib/hooks/useAudiobooks.test.tsx b/tests/lib/hooks/useAudiobooks.test.tsx index a4eb655..1cec198 100644 --- a/tests/lib/hooks/useAudiobooks.test.tsx +++ b/tests/lib/hooks/useAudiobooks.test.tsx @@ -10,12 +10,17 @@ import { render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const useSWRMock = vi.hoisted(() => vi.fn()); +const useSWRInfiniteMock = vi.hoisted(() => vi.fn()); const authenticatedFetcherMock = vi.hoisted(() => vi.fn()); vi.mock('swr', () => ({ default: useSWRMock, })); +vi.mock('swr/infinite', () => ({ + default: useSWRInfiniteMock, +})); + vi.mock('@/lib/utils/api', () => ({ authenticatedFetcher: authenticatedFetcherMock, })); @@ -27,6 +32,7 @@ const HookProbe = ({ label, value }: { label: string; value: any }) => ( describe('useAudiobooks hooks', () => { beforeEach(() => { useSWRMock.mockReset(); + useSWRInfiniteMock.mockReset(); authenticatedFetcherMock.mockReset(); vi.resetModules(); }); @@ -60,25 +66,30 @@ describe('useAudiobooks hooks', () => { }); it('skips search when the query is empty', async () => { - useSWRMock.mockReturnValue({ data: null, error: null, isLoading: false }); + useSWRInfiniteMock.mockReturnValue({ + data: undefined, + error: null, + size: 1, + setSize: vi.fn(), + isLoading: false, + isValidating: false, + }); const { useSearch } = await import('@/lib/hooks/useAudiobooks'); const Probe = () => { - const result = useSearch('', 1); + const result = useSearch(''); return ; }; render(); - expect(useSWRMock).toHaveBeenCalledWith( - null, - authenticatedFetcherMock, - expect.objectContaining({ dedupingInterval: 30000 }) - ); + // useSWRInfinite should be called with a key function + expect(useSWRInfiniteMock).toHaveBeenCalled(); const parsed = JSON.parse(screen.getByTestId('search').textContent || '{}'); expect(parsed.isLoading).toBeFalsy(); + expect(parsed.results).toEqual([]); }); it('requests audiobook details when an ASIN is provided', async () => { diff --git a/tests/services/apprise.provider.test.ts b/tests/services/apprise.provider.test.ts index 3f3f268..ac5ef8f 100644 --- a/tests/services/apprise.provider.test.ts +++ b/tests/services/apprise.provider.test.ts @@ -246,6 +246,102 @@ describe('AppriseProvider', () => { }); }); + describe('send — URL with embedded credentials', () => { + it('extracts credentials and sends Basic auth header with clean URL', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: async () => 'ok', + }); + + const { AppriseProvider } = await import('@/lib/services/notification'); + const provider = new AppriseProvider(); + + await provider.send( + { + serverUrl: 'http://myuser:mypass@apprise:8000', + urls: 'slack://token', + }, + { + event: 'request_approved', + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + timestamp: new Date(), + } + ); + + const fetchCall = fetchMock.mock.calls[0]; + expect(fetchCall[0]).toBe('http://apprise:8000/notify/'); + expect(fetchCall[1].headers['Authorization']).toBe( + `Basic ${Buffer.from('myuser:mypass').toString('base64')}` + ); + }); + + it('decodes URL-encoded special characters in credentials', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: async () => 'ok', + }); + + const { AppriseProvider } = await import('@/lib/services/notification'); + const provider = new AppriseProvider(); + + await provider.send( + { + serverUrl: 'http://user%40domain:p%40ss%3Aword@apprise:8000', + urls: 'slack://token', + }, + { + event: 'request_approved', + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + timestamp: new Date(), + } + ); + + const fetchCall = fetchMock.mock.calls[0]; + expect(fetchCall[0]).toBe('http://apprise:8000/notify/'); + expect(fetchCall[1].headers['Authorization']).toBe( + `Basic ${Buffer.from('user@domain:p@ss:word').toString('base64')}` + ); + }); + + it('authToken (Bearer) takes precedence over URL-embedded credentials', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: async () => 'ok', + }); + + const { AppriseProvider } = await import('@/lib/services/notification'); + const provider = new AppriseProvider(); + + await provider.send( + { + serverUrl: 'http://myuser:mypass@apprise:8000', + urls: 'slack://token', + authToken: 'bearertoken123', + }, + { + event: 'request_approved', + requestId: 'req-1', + title: 'Test Book', + author: 'Test Author', + userName: 'Test User', + timestamp: new Date(), + } + ); + + const fetchCall = fetchMock.mock.calls[0]; + // URL should still be cleaned + expect(fetchCall[0]).toBe('http://apprise:8000/notify/'); + // Bearer token wins over Basic + expect(fetchCall[1].headers['Authorization']).toBe('Bearer bearertoken123'); + }); + }); + describe('notification types by event', () => { it('maps event types to correct Apprise notification types', async () => { fetchMock.mockResolvedValue({