Compare commits

..

7 Commits

Author SHA1 Message Date
kikootwo bfd624e120 Bump package version to 1.0.16
Update package.json version from 1.0.15 to 1.0.16 to reflect a new patch release.
2026-03-02 17:06:01 -05:00
kikootwo b559835390 Merge branch 'main' of https://github.com/kikootwo/ReadMeABook 2026-03-02 17:05:28 -05:00
kikootwo d25a6ebf79 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.
2026-03-02 17:05:21 -05:00
kikootwo b3dad47aba Merge pull request #120 from brombomb/gemini
Add gemini bookdate support
2026-03-02 16:51:43 -05:00
Rob Walsh 7891e31893 Undo formatting noise 2026-03-02 13:58:11 -07:00
Rob Walsh bff74446fe Fix gemini key 2026-03-02 13:48:49 -07:00
Rob Walsh 038c92e49f Add gemini bookdate support 2026-02-28 22:55:59 -07:00
45 changed files with 2160 additions and 323 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "readmeabook", "name": "readmeabook",
"version": "1.0.15", "version": "1.0.16",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "requests" ADD COLUMN "custom_search_terms" TEXT;
+2 -1
View File
@@ -232,6 +232,7 @@ model Request {
importAttempts Int @default(0) @map("import_attempts") importAttempts Int @default(0) @map("import_attempts")
maxImportRetries Int @default(5) @map("max_import_retries") maxImportRetries Int @default(5) @map("max_import_retries")
lastSearchAt DateTime? @map("last_search_at") lastSearchAt DateTime? @map("last_search_at")
customSearchTerms String? @map("custom_search_terms") @db.Text
lastImportAt DateTime? @map("last_import_at") lastImportAt DateTime? @map("last_import_at")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -391,7 +392,7 @@ model ScheduledJob {
model BookDateConfig { model BookDateConfig {
id String @id @default(uuid()) id String @id @default(uuid())
provider String // 'openai' | 'claude' | 'custom' provider String // 'openai' | 'claude' | 'gemini' | 'custom'
apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256) apiKey String @map("api_key") @db.Text // Encrypted at rest (AES-256)
model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929' model String // e.g., 'gpt-4o', 'claude-sonnet-4-5-20250929'
baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints) baseUrl String? @map("base_url") @db.Text // Base URL for custom provider (OpenAI-compatible endpoints)
@@ -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 (
<Modal isOpen={isOpen} onClose={handleClose} title="Adjust Search Terms" size="sm">
<div className="space-y-4">
{/* Original info */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 space-y-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Original Title
</div>
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">{title}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">by {author}</div>
</div>
{/* Search terms input */}
<div>
<label
htmlFor="search-terms"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
>
Search Terms
</label>
<input
id="search-terms"
type="text"
value={searchTerms}
onChange={(e) => 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 && (
<button
onClick={handleReset}
disabled={isLoading}
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-50"
>
Reset to original title
</button>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => save(false)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => save(true)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{isSavingAndSearching ? 'Saving...' : 'Save & Search'}
</button>
</div>
</div>
</Modal>
);
}
@@ -28,6 +28,8 @@ interface RecentRequest {
completedAt: Date | null; completedAt: Date | null;
errorMessage: string | null; errorMessage: string | null;
torrentUrl?: string | null; torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
} }
interface User { 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 // Render loading state
if (isLoading && !data) { if (isLoading && !data) {
return ( return (
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
Ebook Ebook
</span> </span>
)} )}
{request.customSearchTerms && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200"
title={`Custom search: ${request.customSearchTerms}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Custom Search
</span>
)}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
{request.author} {request.author}
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
type: request.type, type: request.type,
asin: request.asin, asin: request.asin,
torrentUrl: request.torrentUrl, torrentUrl: request.torrentUrl,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms,
}} }}
onDelete={handleDeleteClick} onDelete={handleDeleteClick}
onManualSearch={handleManualSearch} onManualSearch={handleManualSearch}
onCancel={handleCancel} onCancel={handleCancel}
onRetryDownload={handleRetryDownload}
onViewDetails={(asin) => handleViewDetails(asin, request.status)} onViewDetails={(asin) => handleViewDetails(asin, request.status)}
onFetchEbook={handleFetchEbook} onFetchEbook={handleFetchEbook}
onSearchTermsUpdated={() => mutate(apiUrl)}
ebookSidecarEnabled={ebookSidecarEnabled} ebookSidecarEnabled={ebookSidecarEnabled}
annasArchiveBaseUrl={annasArchiveBaseUrl} annasArchiveBaseUrl={annasArchiveBaseUrl}
isLoading={isDeleting || isFetchingEbook} isLoading={isDeleting || isFetchingEbook}
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
}} }}
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'} isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
requestStatus={viewDetailsStatus} requestStatus={viewDetailsStatus}
hideRequestActions
/> />
)} )}
</div> </div>
@@ -10,6 +10,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition'; import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export interface RequestActionsDropdownProps { export interface RequestActionsDropdownProps {
@@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps {
type?: 'audiobook' | 'ebook'; type?: 'audiobook' | 'ebook';
asin?: string | null; asin?: string | null;
torrentUrl?: string | null; torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
}; };
onDelete: (requestId: string, title: string) => void; onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => Promise<void>; onManualSearch: (requestId: string) => Promise<void>;
onCancel: (requestId: string) => Promise<void>; onCancel: (requestId: string) => Promise<void>;
onRetryDownload?: (requestId: string) => Promise<void>;
onViewDetails?: (asin: string) => void; onViewDetails?: (asin: string) => void;
onFetchEbook?: (requestId: string) => Promise<void>; onFetchEbook?: (requestId: string) => Promise<void>;
onSearchTermsUpdated?: () => void;
ebookSidecarEnabled?: boolean; ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string; annasArchiveBaseUrl?: string;
isLoading?: boolean; isLoading?: boolean;
@@ -37,8 +42,10 @@ export function RequestActionsDropdown({
onDelete, onDelete,
onManualSearch, onManualSearch,
onCancel, onCancel,
onRetryDownload,
onViewDetails, onViewDetails,
onFetchEbook, onFetchEbook,
onSearchTermsUpdated,
ebookSidecarEnabled = false, ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li', annasArchiveBaseUrl = 'https://annas-archive.li',
isLoading = false, isLoading = false,
@@ -46,6 +53,7 @@ export function RequestActionsDropdown({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false); const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false); const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen); const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
// Determine request type // Determine request type
@@ -57,6 +65,8 @@ export function RequestActionsDropdown({
// Determine available actions based on status and type // Determine available actions based on status and type
// Ebooks don't support manual/interactive search (Anna's Archive only) // Ebooks don't support manual/interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status); 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 canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const canDelete = true; // Admins can always delete const canDelete = true; // Admins can always delete
@@ -123,11 +133,27 @@ export function RequestActionsDropdown({
setShowInteractiveSearch(true); setShowInteractiveSearch(true);
}; };
const handleAdjustSearchTerms = () => {
setIsOpen(false);
setShowAdjustSearchTerms(true);
};
const handleInteractiveSearchEbook = () => { const handleInteractiveSearchEbook = () => {
setIsOpen(false); setIsOpen(false);
setShowInteractiveSearchEbook(true); 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 () => { const handleCancel = async () => {
setIsOpen(false); setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) { if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
@@ -253,6 +279,35 @@ export function RequestActionsDropdown({
</button> </button>
)} )}
{/* Adjust Search Terms */}
{canAdjustSearchTerms && (
<button
onClick={handleAdjustSearchTerms}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span className="flex items-center gap-1.5">
Adjust Search Terms
{request.customSearchTerms && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
)}
</span>
</button>
)}
{/* View Source */} {/* View Source */}
{canViewSource && viewSourceUrl && ( {canViewSource && viewSourceUrl && (
<a <a
@@ -328,8 +383,32 @@ export function RequestActionsDropdown({
</button> </button>
)} )}
{/* Divider if we have search/view actions and other actions */} {/* Retry Download */}
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && ( {canRetryDownload && (
<button
onClick={handleRetryDownload}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
role="menuitem"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Retry Download
</button>
)}
{/* Divider if we have search/view/retry actions and other actions */}
{(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" /> <div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)} )}
@@ -358,7 +437,7 @@ export function RequestActionsDropdown({
)} )}
{/* Divider before delete */} {/* Divider before delete */}
{canDelete && (canSearch || canCancel) && ( {canDelete && (canSearch || canRetryDownload || canCancel) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" /> <div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)} )}
@@ -421,6 +500,7 @@ export function RequestActionsDropdown({
title: request.title, title: request.title,
author: request.author, author: request.author,
}} }}
customSearchTerms={request.customSearchTerms}
/> />
{/* Interactive Search Modal (Ebook) */} {/* Interactive Search Modal (Ebook) */}
@@ -434,6 +514,17 @@ export function RequestActionsDropdown({
}} }}
searchMode="ebook" searchMode="ebook"
/> />
{/* Adjust Search Terms Modal */}
<AdjustSearchTermsModal
isOpen={showAdjustSearchTerms}
onClose={() => setShowAdjustSearchTerms(false)}
requestId={request.requestId}
title={request.title}
author={request.author}
currentSearchTerms={request.customSearchTerms}
onSuccess={onSearchTermsUpdated}
/>
</> </>
); );
} }
@@ -90,6 +90,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
> >
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option> <option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
<option value="custom">Custom (OpenAI-compatible)</option> <option value="custom">Custom (OpenAI-compatible)</option>
</select> </select>
</div> </div>
@@ -136,7 +137,7 @@ export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
? 'Leave blank for local models' ? 'Leave blank for local models'
: configured : configured
? '••••••••••••••••' ? '••••••••••••••••'
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...') : (provider === 'openai' ? 'sk-...' : provider === 'gemini' ? 'AIza...' : 'sk-ant-...')
} }
/> />
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
+2 -2
View File
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
const fs = await import('fs/promises'); const fs = await import('fs/promises');
const body = await request.json(); const body = await request.json();
const { folderPath, asin } = body; const { folderPath, asin, cleanupSource } = body;
let { audiobookId } = body; let { audiobookId } = body;
// Validate required fields // Validate required fields
@@ -242,7 +242,7 @@ export async function POST(request: NextRequest) {
// Queue organize_files job // Queue organize_files job
const jobQueue = getJobQueueService(); 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}`); logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
@@ -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<typeof getJobQueueService>,
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<void> {
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);
}
@@ -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 }
);
}
});
});
}
+2
View File
@@ -139,6 +139,8 @@ export async function GET(request: NextRequest) {
completedAt: request.completedAt, completedAt: request.completedAt,
errorMessage: request.errorMessage, errorMessage: request.errorMessage,
torrentUrl: request.downloadHistory[0]?.torrentUrl || null, torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms || null,
})); }));
return NextResponse.json({ return NextResponse.json({
+9 -5
View File
@@ -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 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 // Enrich with library availability and request status
const userId = currentUser.sub || undefined; 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({ return NextResponse.json({
success: true, success: true,
books: enrichedBooks, books: enrichedBooks,
authorName: authorName.trim(), authorName: authorName.trim(),
authorAsin: asin, authorAsin: asin,
totalBooks: enrichedBooks.length, totalBooks: result.totalResults || enrichedBooks.length,
hasMore: result.hasMore,
page: result.page,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to fetch author books', { error: error instanceof Error ? error.message : String(error) });
+3 -3
View File
@@ -59,9 +59,9 @@ async function saveConfig(req: AuthenticatedRequest) {
); );
} }
if (!['openai', 'claude', 'custom'].includes(provider)) { if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 } { status: 400 }
); );
} }
@@ -107,7 +107,7 @@ async function saveConfig(req: AuthenticatedRequest) {
// No new API key, use existing one // No new API key, use existing one
encryptedApiKeyToUse = existingConfig.apiKey; encryptedApiKeyToUse = existingConfig.apiKey;
} else { } else {
// API key required for OpenAI/Claude // API key required for OpenAI/Claude/Gemini
return NextResponse.json( return NextResponse.json(
{ error: 'API key is required' }, { error: 'API key is required' },
{ status: 400 } { status: 400 }
+48 -4
View File
@@ -52,6 +52,30 @@ async function fetchClaudeModels(apiKey: string): Promise<{ id: string; name: st
return allModels; return allModels;
} }
// Fetch available Gemini models from the Google API
async function fetchGeminiModels(apiKey: string): Promise<{ id: string; name: string }[]> {
const response = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models',
{ headers: { 'x-goog-api-key': apiKey } }
);
if (!response.ok) {
const errorText = await response.text();
logger.error('Gemini API error', { error: errorText });
throw new Error('Invalid Gemini API key or connection failed');
}
const data = await response.json();
return (data.models || [])
.filter((m: any) => m.name?.startsWith('models/gemini-') && m.supportedGenerationMethods?.includes('generateContent'))
.map((m: any) => ({
id: m.name.replace('models/', ''),
name: m.displayName || m.name.replace('models/', ''),
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
}
// Helper functions for custom provider // Helper functions for custom provider
function isValidBaseUrl(url: string): boolean { function isValidBaseUrl(url: string): boolean {
try { try {
@@ -79,9 +103,9 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
); );
} }
if (!['openai', 'claude', 'custom'].includes(provider)) { if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 } { status: 400 }
); );
} }
@@ -193,6 +217,16 @@ async function authenticatedHandler(req: AuthenticatedRequest) {
{ status: 400 } { status: 400 }
); );
} }
} else if (provider === 'gemini') {
// Gemini: Fetch models dynamically from the Google API
try {
models = await fetchGeminiModels(testApiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }
);
}
} else if (provider === 'custom') { } else if (provider === 'custom') {
// Custom: Fetch models from custom OpenAI-compatible endpoint // Custom: Fetch models from custom OpenAI-compatible endpoint
const normalizedUrl = normalizeBaseUrl(testBaseUrl); const normalizedUrl = normalizeBaseUrl(testBaseUrl);
@@ -291,9 +325,9 @@ async function unauthenticatedHandler(req: NextRequest) {
); );
} }
if (!['openai', 'claude', 'custom'].includes(provider)) { if (!['openai', 'claude', 'custom', 'gemini'].includes(provider)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid provider. Must be "openai", "claude", or "custom"' }, { error: 'Invalid provider. Must be "openai", "claude", "custom", or "gemini"' },
{ status: 400 } { status: 400 }
); );
} }
@@ -363,6 +397,16 @@ async function unauthenticatedHandler(req: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
} else if (provider === 'gemini') {
// Gemini: Fetch models dynamically
try {
models = await fetchGeminiModels(apiKey);
} catch {
return NextResponse.json(
{ error: 'Invalid Gemini API key or connection failed' },
{ status: 400 }
);
}
} else if (provider === 'custom') { } else if (provider === 'custom') {
// Custom: Fetch models from custom OpenAI-compatible endpoint // Custom: Fetch models from custom OpenAI-compatible endpoint
const normalizedUrl = normalizeBaseUrl(baseUrl); const normalizedUrl = normalizeBaseUrl(baseUrl);
@@ -125,8 +125,8 @@ export async function POST(
logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`); logger.info(`Skipping ${skippedIndexers.length} indexer(s) with no audiobook categories: ${skippedNames}`);
} }
// Use custom title if provided, otherwise use audiobook's title // Use custom title if provided, then custom search terms, then audiobook's title
const searchTitle = customTitle || requestRecord.audiobook.title; const searchTitle = customTitle || requestRecord.customSearchTerms || requestRecord.audiobook.title;
const searchAuthor = requestRecord.audiobook.author; const searchAuthor = requestRecord.audiobook.author;
logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle }); logger.info(`Searching ${indexersConfig.length - skippedIndexers.length} enabled indexers in ${groups.length} group${groups.length > 1 ? 's' : ''}`, { searchTitle });
+7 -3
View File
@@ -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) { if (!detail) {
return NextResponse.json( return NextResponse.json(
{ error: 'NotFound', message: 'Series not found' }, { error: 'NotFound', message: 'Series not found' },
@@ -51,7 +53,7 @@ export async function GET(
const userId = currentUser.sub || undefined; const userId = currentUser.sub || undefined;
const enrichedBooks = await enrichAudiobooksWithMatches(detail.books, userId); 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({ return NextResponse.json({
success: true, success: true,
@@ -59,6 +61,8 @@ export async function GET(
...detail, ...detail,
books: enrichedBooks, books: enrichedBooks,
}, },
hasMore: detail.hasMore,
page: detail.page,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to fetch series detail', { logger.error('Failed to fetch series detail', {
+42 -12
View File
@@ -5,16 +5,17 @@
'use client'; 'use client';
import { use, useCallback } from 'react'; import { use, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard'; import { AuthorDetailCard, AuthorDetailSkeleton } from '@/components/authors/AuthorDetailCard';
import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow'; import { SimilarAuthorsRow, SimilarAuthorsSkeleton } from '@/components/authors/SimilarAuthorsRow';
import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors'; import { useAuthorDetail, useAuthorBooks } from '@/lib/hooks/useAuthors';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls'; import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
export default function AuthorDetailPage({ export default function AuthorDetailPage({
@@ -27,11 +28,11 @@ export default function AuthorDetailPage({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const fromAuthorName = searchParams.get('from'); const fromAuthorName = searchParams.get('from');
const { author, isLoading: authorLoading } = useAuthorDetail(asin); const { author, isLoading: authorLoading } = useAuthorDetail(asin);
const { books, totalBooks, isLoading: booksLoading } = useAuthorBooks( const { books, totalBooks, hasMore, isLoading: booksLoading, isLoadingMore, loadMore } = useAuthorBooks(
asin, asin,
author?.name || null author?.name || null
); );
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences(); const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /authors // Use browser back if we came from within the app, otherwise fallback to /authors
@@ -42,6 +43,20 @@ export default function AuthorDetailPage({
} }
}, [router]); }, [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 ( return (
<ProtectedRoute> <ProtectedRoute>
<div className="min-h-screen"> <div className="min-h-screen">
@@ -91,27 +106,42 @@ export default function AuthorDetailPage({
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate"> <h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books Books
</h2> </h2>
{!booksLoading && totalBooks > 0 && ( {!booksLoading && booksCountText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap"> <span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalBooks} title{totalBooks !== 1 ? 's' : ''}) ({booksCountText})
</span> </span>
)} )}
<div className="ml-auto flex items-center gap-1"> <SectionToolbar
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} /> hideAvailable={hideAvailable}
<CardSizeControls size={cardSize} onSizeChange={setCardSize} /> onToggleHideAvailable={setHideAvailable}
</div> squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div> </div>
</div> </div>
</div> </div>
{/* Books Grid */} {/* Books Grid */}
<AudiobookGrid <AudiobookGrid
audiobooks={books} audiobooks={filteredBooks}
isLoading={booksLoading} isLoading={booksLoading}
emptyMessage={`No books found for ${author.name}`} emptyMessage={`No books found for ${author.name}`}
cardSize={cardSize} cardSize={cardSize}
squareCovers={squareCovers} squareCovers={squareCovers}
/> />
{/* Load More Bar */}
{filteredBooks.length > 0 && (
<LoadMoreBar
loadedCount={filteredBooks.length}
totalCount={totalBooks > 0 ? totalBooks : undefined}
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={loadMore}
/>
)}
</div> </div>
)} )}
</main> </main>
+32 -15
View File
@@ -5,20 +5,19 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef, useMemo } from 'react';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; 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 { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { StickyPagination } from '@/components/ui/StickyPagination'; import { StickyPagination } from '@/components/ui/StickyPagination';
import { CardSizeControls } from '@/components/ui/CardSizeControls'; import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
export default function HomePage() { export default function HomePage() {
const [popularPage, setPopularPage] = useState(1); const [popularPage, setPopularPage] = useState(1);
const [newReleasesPage, setNewReleasesPage] = 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 // Refs for auto-scrolling to section tops
const popularSectionRef = useRef<HTMLElement>(null); const popularSectionRef = useRef<HTMLElement>(null);
@@ -39,6 +38,16 @@ export default function HomePage() {
message: newReleasesMessage, message: newReleasesMessage,
} = useAudiobooks('new-releases', 20, newReleasesPage); } = 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 // Handle page changes with auto-scroll to section top
const handlePopularPageChange = (page: number) => { const handlePopularPageChange = (page: number) => {
setPopularPage(page); setPopularPage(page);
@@ -66,10 +75,14 @@ export default function HomePage() {
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate"> <h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
Popular Audiobooks Popular Audiobooks
</h2> </h2>
<div className="ml-auto flex items-center gap-1"> <SectionToolbar
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} /> hideAvailable={hideAvailable}
<CardSizeControls size={cardSize} onSizeChange={setCardSize} /> onToggleHideAvailable={setHideAvailable}
</div> squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -87,7 +100,7 @@ export default function HomePage() {
</div> </div>
) : ( ) : (
<AudiobookGrid <AudiobookGrid
audiobooks={popular} audiobooks={filteredPopular}
isLoading={loadingPopular} isLoading={loadingPopular}
emptyMessage="No popular audiobooks available" emptyMessage="No popular audiobooks available"
cardSize={cardSize} cardSize={cardSize}
@@ -107,10 +120,14 @@ export default function HomePage() {
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate"> <h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100 truncate">
New Releases New Releases
</h2> </h2>
<div className="ml-auto flex items-center gap-1"> <SectionToolbar
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} /> hideAvailable={hideAvailable}
<CardSizeControls size={cardSize} onSizeChange={setCardSize} /> onToggleHideAvailable={setHideAvailable}
</div> squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -128,7 +145,7 @@ export default function HomePage() {
</div> </div>
) : ( ) : (
<AudiobookGrid <AudiobookGrid
audiobooks={newReleases} audiobooks={filteredNewReleases}
isLoading={loadingNewReleases} isLoading={loadingNewReleases}
emptyMessage="No new releases available" emptyMessage="No new releases available"
cardSize={cardSize} cardSize={cardSize}
+41 -37
View File
@@ -5,41 +5,48 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useSearch } from '@/lib/hooks/useAudiobooks'; import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { useSearch, Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls'; import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
export default function SearchPage() { export default function SearchPage() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState('');
const [page, setPage] = useState(1); const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences();
// Debounce search query // Debounce search query
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDebouncedQuery(query); setDebouncedQuery(query);
setPage(1); // Reset to first page on new search
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [query]); }, [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) => { const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setPage(1);
}, []); }, []);
const handleLoadMore = useCallback(() => { // Header count text: reflects filtered counts
setPage((prev) => prev + 1); 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 ( return (
<ProtectedRoute> <ProtectedRoute>
@@ -113,45 +120,42 @@ export default function SearchPage() {
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate"> <h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Search Results Search Results
</h2> </h2>
{!isLoading && totalResults > 0 && ( {!isLoading && countText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap"> <span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''}) ({countText})
</span> </span>
)} )}
<div className="ml-auto flex items-center gap-1"> <SectionToolbar
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} /> hideAvailable={hideAvailable}
<CardSizeControls size={cardSize} onSizeChange={setCardSize} /> onToggleHideAvailable={setHideAvailable}
</div> squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div> </div>
</div> </div>
</div> </div>
{/* Results Grid */} {/* Results Grid */}
<AudiobookGrid <AudiobookGrid
audiobooks={results} audiobooks={filteredResults}
isLoading={!!(isLoading && page === 1)} isLoading={isLoading}
emptyMessage={`No results found for "${debouncedQuery}"`} emptyMessage={`No results found for "${debouncedQuery}"`}
cardSize={cardSize} cardSize={cardSize}
squareCovers={squareCovers} squareCovers={squareCovers}
/> />
{/* Load More */} {/* Load More Bar */}
{hasMore && !isLoading && ( {filteredResults.length > 0 && (
<div className="flex justify-center"> <LoadMoreBar
<button loadedCount={filteredResults.length}
onClick={handleLoadMore} totalCount={totalResults}
className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors" hasMore={hasMore}
> isLoading={isLoadingMore}
Load More Results onLoadMore={loadMore}
</button> itemLabel="results"
</div> />
)}
{/* Loading More Indicator */}
{isLoading && page > 1 && (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)} )}
</div> </div>
) : ( ) : (
+46 -12
View File
@@ -5,16 +5,17 @@
'use client'; 'use client';
import { use, useCallback } from 'react'; import { use, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid'; import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { LoadMoreBar } from '@/components/ui/LoadMoreBar';
import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard'; import { SeriesDetailCard, SeriesDetailSkeleton } from '@/components/series/SeriesDetailCard';
import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow'; import { SimilarSeriesRow, SimilarSeriesSkeleton } from '@/components/series/SimilarSeriesRow';
import { useSeriesDetail } from '@/lib/hooks/useSeries'; import { useSeriesDetail } from '@/lib/hooks/useSeries';
import { Audiobook } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { CardSizeControls } from '@/components/ui/CardSizeControls'; import { SectionToolbar } from '@/components/ui/SectionToolbar';
import { SquareCoversToggle } from '@/components/ui/SquareCoversToggle';
import { usePreferences } from '@/contexts/PreferencesContext'; import { usePreferences } from '@/contexts/PreferencesContext';
export default function SeriesDetailPage({ export default function SeriesDetailPage({
@@ -26,8 +27,8 @@ export default function SeriesDetailPage({
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const fromSeriesTitle = searchParams.get('from'); const fromSeriesTitle = searchParams.get('from');
const { series, isLoading: seriesLoading } = useSeriesDetail(asin); const { series, hasMore, isLoading: seriesLoading, isLoadingMore, loadMore } = useSeriesDetail(asin);
const { cardSize, setCardSize, squareCovers, setSquareCovers } = usePreferences(); const { cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable } = usePreferences();
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
// Use browser back if we came from within the app, otherwise fallback to /series // Use browser back if we came from within the app, otherwise fallback to /series
@@ -38,6 +39,24 @@ export default function SeriesDetailPage({
} }
}, [router]); }, [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 ( return (
<ProtectedRoute> <ProtectedRoute>
<div className="min-h-screen"> <div className="min-h-screen">
@@ -87,27 +106,42 @@ export default function SeriesDetailPage({
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate"> <h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">
Books in Series Books in Series
</h2> </h2>
{series.books.length > 0 && ( {booksCountText && (
<span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap"> <span className="text-sm text-gray-600 dark:text-gray-400 hidden sm:inline whitespace-nowrap">
({series.books.length} title{series.books.length !== 1 ? 's' : ''}) ({booksCountText})
</span> </span>
)} )}
<div className="ml-auto flex items-center gap-1"> <SectionToolbar
<SquareCoversToggle enabled={squareCovers} onToggle={setSquareCovers} /> hideAvailable={hideAvailable}
<CardSizeControls size={cardSize} onSizeChange={setCardSize} /> onToggleHideAvailable={setHideAvailable}
</div> squareCovers={squareCovers}
onToggleSquareCovers={setSquareCovers}
cardSize={cardSize}
onCardSizeChange={setCardSize}
/>
</div> </div>
</div> </div>
</div> </div>
{/* Books Grid */} {/* Books Grid */}
<AudiobookGrid <AudiobookGrid
audiobooks={series.books} audiobooks={filteredBooks}
isLoading={seriesLoading} isLoading={seriesLoading}
emptyMessage={`No books found for ${series.title}`} emptyMessage={`No books found for ${series.title}`}
cardSize={cardSize} cardSize={cardSize}
squareCovers={squareCovers} squareCovers={squareCovers}
/> />
{/* Load More Bar */}
{filteredBooks.length > 0 && (
<LoadMoreBar
loadedCount={filteredBooks.length}
totalCount={series.bookCount > 0 ? series.bookCount : undefined}
hasMore={hasMore}
isLoading={isLoadingMore}
onLoadMore={loadMore}
/>
)}
</div> </div>
)} )}
</main> </main>
+2 -1
View File
@@ -134,6 +134,7 @@ export function BookDateStep({
> >
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="claude">Claude (Anthropic)</option> <option value="claude">Claude (Anthropic)</option>
<option value="gemini">Google Gemini</option>
</select> </select>
</div> </div>
@@ -152,7 +153,7 @@ export function BookDateStep({
onUpdate('bookdateConfigured', false); onUpdate('bookdateConfigured', false);
onUpdate('bookdateModels', []); onUpdate('bookdateModels', []);
}} }}
placeholder={bookdateProvider === 'openai' ? 'sk-...' : 'sk-ant-...'} placeholder={bookdateProvider === 'openai' ? 'sk-...' : bookdateProvider === 'gemini' ? 'AIza...' : 'sk-ant-...'}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
/> />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
@@ -59,6 +59,9 @@ export function ManualImportBrowser({
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null); const [importError, setImportError] = useState<string | null>(null);
// Cleanup source toggle
const [cleanupSource, setCleanupSource] = useState(false);
// Hover state for folder icon swap // Hover state for folder icon swap
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null); const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
@@ -188,6 +191,7 @@ export function ManualImportBrowser({
body: JSON.stringify({ body: JSON.stringify({
asin: audiobook.asin, asin: audiobook.asin,
folderPath: selectedPath, folderPath: selectedPath,
cleanupSource,
}), }),
}); });
const data = await res.json(); const data = await res.json();
@@ -288,6 +292,8 @@ export function ManualImportBrowser({
isImporting={isImporting} isImporting={isImporting}
importError={importError} importError={importError}
slideClass={slideClass} slideClass={slideClass}
cleanupSource={cleanupSource}
onCleanupSourceChange={setCleanupSource}
onBack={handleBackToBrowse} onBack={handleBackToBrowse}
onStartImport={handleStartImport} onStartImport={handleStartImport}
/> />
@@ -22,6 +22,8 @@ interface ConfirmPhaseProps {
isImporting: boolean; isImporting: boolean;
importError: string | null; importError: string | null;
slideClass: string; slideClass: string;
cleanupSource: boolean;
onCleanupSourceChange: (value: boolean) => void;
onBack: () => void; onBack: () => void;
onStartImport: () => void; onStartImport: () => void;
} }
@@ -35,6 +37,8 @@ export function ConfirmPhase({
isImporting, isImporting,
importError, importError,
slideClass, slideClass,
cleanupSource,
onCleanupSourceChange,
onBack, onBack,
onStartImport, onStartImport,
}: ConfirmPhaseProps) { }: ConfirmPhaseProps) {
@@ -99,6 +103,30 @@ export function ConfirmPhase({
))} ))}
</div> </div>
</div> </div>
{/* Cleanup source toggle */}
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
Cleanup source files
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Delete original files after successful import
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={cleanupSource}
onChange={(e) => onCleanupSourceChange(e.target.checked)}
disabled={isImporting}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div> </div>
{/* Error display */} {/* Error display */}
@@ -34,6 +34,7 @@ interface InteractiveTorrentSearchModalProps {
title: string; title: string;
author: string; author: string;
}; };
customSearchTerms?: string | null; // Optional - admin-set custom search terms override
fullAudiobook?: Audiobook; // Optional - only provided when called from details modal fullAudiobook?: Audiobook; // Optional - only provided when called from details modal
onSuccess?: () => void; onSuccess?: () => void;
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
@@ -87,6 +88,7 @@ export function InteractiveTorrentSearchModal({
requestId, requestId,
asin, asin,
audiobook, audiobook,
customSearchTerms,
fullAudiobook, fullAudiobook,
onSuccess, onSuccess,
searchMode = 'audiobook', searchMode = 'audiobook',
@@ -114,7 +116,7 @@ export function InteractiveTorrentSearchModal({
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]); const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null); const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
const [searchTitle, setSearchTitle] = useState(audiobook.title); const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
const [isCustomConfirming, setIsCustomConfirming] = useState(false); const [isCustomConfirming, setIsCustomConfirming] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -153,9 +155,9 @@ export function InteractiveTorrentSearchModal({
// Reset search title when modal opens/closes or audiobook changes // Reset search title when modal opens/closes or audiobook changes
useEffect(() => { useEffect(() => {
setSearchTitle(audiobook.title); setSearchTitle(customSearchTerms || audiobook.title);
setResults([]); setResults([]);
}, [isOpen, audiobook.title]); }, [isOpen, audiobook.title, customSearchTerms]);
// Perform search when modal opens // Perform search when modal opens
useEffect(() => { useEffect(() => {
+81
View File
@@ -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 (
<button
onClick={() => onToggle(!enabled)}
aria-label={enabled ? 'Show available titles' : 'Hide available titles'}
aria-pressed={enabled}
title={enabled ? 'Hide available (on)' : 'Hide available (off)'}
className={`
p-1.5 rounded-md transition-all duration-200
${enabled
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
: 'text-gray-600 dark:text-gray-400 hover:bg-white/20 dark:hover:bg-gray-700/50'
}
`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{enabled ? (
<>
{/* Eye with slash — hidden state */}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 3l18 18"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.5 10.677a2 2 0 002.823 2.823"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c3.718 0 7.111 3.009 9 6-.947 1.498-2.057 2.876-3.362 3.939"
/>
</>
) : (
<>
{/* Open eye — visible state */}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6c3.718 0 7.111 3.009 9 6-1.889 2.991-5.282 6-9 6s-7.111-3.009-9-6c1.889-2.991 5.282-6 9-6z"
/>
<circle
cx="12"
cy="12"
r="2"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</>
)}
</svg>
</button>
);
}
+82
View File
@@ -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 (
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center justify-between">
{/* Left: Count */}
<span className="text-sm text-gray-600 dark:text-gray-400">
{countText}
</span>
{/* Right: Action */}
{allLoaded ? (
<span className="inline-flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<CheckCircleIcon className="w-4 h-4" />
Complete
</span>
) : (
<button
onClick={onLoadMore}
disabled={isLoading}
className="inline-flex items-center gap-2 px-4 py-1.5 text-sm font-medium
text-gray-700 dark:text-gray-300
border border-gray-300 dark:border-gray-600 rounded-lg
hover:bg-gray-100 dark:hover:bg-gray-700
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isLoading ? (
<>
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Loading...
</>
) : (
'Load more'
)}
</button>
)}
</div>
</div>
);
}
+175
View File
@@ -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 (
<div className="ml-auto flex items-center gap-1">
{/* Inline controls — visible at sm and above */}
<div className="hidden sm:flex items-center gap-1">
<HideAvailableToggle enabled={hideAvailable} onToggle={onToggleHideAvailable} />
<SquareCoversToggle enabled={squareCovers} onToggle={onToggleSquareCovers} />
<CardSizeControls size={cardSize} onSizeChange={onCardSizeChange} />
</div>
{/* Collapsed ellipsis trigger — visible below sm */}
<div className="sm:hidden" ref={containerRef}>
<button
onClick={() => setIsOpen(!isOpen)}
aria-label="View options"
aria-expanded={isOpen}
className={`
p-1.5 rounded-md transition-all duration-200
${isOpen
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30'
: 'text-gray-600 dark:text-gray-400 hover:bg-white/20 dark:hover:bg-gray-700/50'
}
`}
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
{/* Portal dropdown */}
{isOpen && typeof document !== 'undefined' && style && createPortal(
<div
ref={dropdownRef}
style={style}
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/10 z-50 py-1 min-w-[220px] animate-in fade-in duration-150"
>
{/* Hide Available */}
<button
onClick={() => onToggleHideAvailable(!hideAvailable)}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
<span className={`
p-1 rounded-md transition-all duration-200
${hideAvailable
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
: 'text-gray-500 dark:text-gray-400'
}
`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{hideAvailable ? (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3l18 18" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.5 10.677a2 2 0 002.823 2.823" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c3.718 0 7.111 3.009 9 6-.947 1.498-2.057 2.876-3.362 3.939" />
</>
) : (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6c3.718 0 7.111 3.009 9 6-1.889 2.991-5.282 6-9 6s-7.111-3.009-9-6c1.889-2.991 5.282-6 9-6z" />
<circle cx="12" cy="12" r="2" strokeWidth={2} />
</>
)}
</svg>
</span>
<span className="text-gray-700 dark:text-gray-300">Hide Available</span>
{hideAvailable && (
<span className="ml-auto text-xs text-blue-600 dark:text-blue-400 font-medium">On</span>
)}
</button>
{/* Square Covers */}
<button
onClick={() => onToggleSquareCovers(!squareCovers)}
className="w-full flex items-center gap-3 px-3 py-2.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
>
<span className={`
p-1 rounded-md transition-all duration-200
${squareCovers
? 'bg-blue-500/20 dark:bg-blue-400/20 text-blue-600 dark:text-blue-400 ring-1 ring-blue-500/30 dark:ring-blue-400/30 shadow-inner'
: 'text-gray-500 dark:text-gray-400'
}
`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" strokeWidth={2} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9h4M3 15h4M21 9h-4M21 15h-4" opacity={squareCovers ? 1 : 0.4} />
</svg>
</span>
<span className="text-gray-700 dark:text-gray-300">Square Covers</span>
{squareCovers && (
<span className="ml-auto text-xs text-blue-600 dark:text-blue-400 font-medium">On</span>
)}
</button>
{/* Divider */}
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
{/* Card Size */}
<div className="flex items-center gap-3 px-3 py-2.5 text-sm">
<span className="p-1 text-gray-500 dark:text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<span className="text-gray-700 dark:text-gray-300">Card Size</span>
<div className="ml-auto">
<CardSizeControls size={cardSize} onSizeChange={onCardSizeChange} />
</div>
</div>
</div>,
document.body
)}
</div>
</div>
);
}
+27 -1
View File
@@ -10,6 +10,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
interface Preferences { interface Preferences {
cardSize: number; // 1-9, default 5 cardSize: number; // 1-9, default 5
squareCovers: boolean; // true = square (1:1), false = rectangle (2:3) squareCovers: boolean; // true = square (1:1), false = rectangle (2:3)
hideAvailable: boolean; // true = hide "In Your Library" titles
} }
interface PreferencesContextType { interface PreferencesContextType {
@@ -17,6 +18,8 @@ interface PreferencesContextType {
setCardSize: (size: number) => void; setCardSize: (size: number) => void;
squareCovers: boolean; squareCovers: boolean;
setSquareCovers: (enabled: boolean) => void; setSquareCovers: (enabled: boolean) => void;
hideAvailable: boolean;
setHideAvailable: (enabled: boolean) => void;
} }
const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined); const PreferencesContext = createContext<PreferencesContextType | undefined>(undefined);
@@ -24,6 +27,7 @@ const PreferencesContext = createContext<PreferencesContextType | undefined>(und
const DEFAULT_PREFERENCES: Preferences = { const DEFAULT_PREFERENCES: Preferences = {
cardSize: 5, cardSize: 5,
squareCovers: true, squareCovers: true,
hideAvailable: false,
}; };
const STORAGE_KEY = 'preferences'; const STORAGE_KEY = 'preferences';
@@ -31,6 +35,7 @@ const STORAGE_KEY = 'preferences';
export function PreferencesProvider({ children }: { children: ReactNode }) { export function PreferencesProvider({ children }: { children: ReactNode }) {
const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize); const [cardSize, setCardSizeState] = useState<number>(DEFAULT_PREFERENCES.cardSize);
const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers); const [squareCovers, setSquareCoversState] = useState<boolean>(DEFAULT_PREFERENCES.squareCovers);
const [hideAvailable, setHideAvailableState] = useState<boolean>(DEFAULT_PREFERENCES.hideAvailable);
// Load preferences from localStorage on mount // Load preferences from localStorage on mount
useEffect(() => { useEffect(() => {
@@ -49,11 +54,14 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
} }
// Load squareCovers preference (defaults to false if not set) // Load squareCovers preference (defaults to false if not set)
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers); setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
// Load hideAvailable preference
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
} }
} catch (error) { } catch (error) {
console.error('Failed to load preferences from localStorage:', error); console.error('Failed to load preferences from localStorage:', error);
setCardSizeState(DEFAULT_PREFERENCES.cardSize); setCardSizeState(DEFAULT_PREFERENCES.cardSize);
setSquareCoversState(DEFAULT_PREFERENCES.squareCovers); 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) // Listen for storage changes in other tabs (cross-tab sync)
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -106,6 +130,8 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
} }
// Sync squareCovers preference // Sync squareCovers preference
setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers); setSquareCoversState(preferences.squareCovers ?? DEFAULT_PREFERENCES.squareCovers);
// Sync hideAvailable preference
setHideAvailableState(preferences.hideAvailable ?? DEFAULT_PREFERENCES.hideAvailable);
} catch (error) { } catch (error) {
console.error('Failed to parse preferences from storage event:', error); console.error('Failed to parse preferences from storage event:', error);
} }
@@ -119,7 +145,7 @@ export function PreferencesProvider({ children }: { children: ReactNode }) {
}, []); }, []);
return ( return (
<PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers }}> <PreferencesContext.Provider value={{ cardSize, setCardSize, squareCovers, setSquareCovers, hideAvailable, setHideAvailable }}>
{children} {children}
</PreferencesContext.Provider> </PreferencesContext.Provider>
); );
+69 -1
View File
@@ -547,7 +547,7 @@ export async function buildAIPrompt(
/** /**
* Call AI API to get recommendations * Call AI API to get recommendations
* @param provider - 'openai' | 'claude' * @param provider - 'openai' | 'claude' | 'gemini' | 'custom'
* @param model - Model ID * @param model - Model ID
* @param encryptedApiKey - Encrypted API key * @param encryptedApiKey - Encrypted API key
* @param prompt - JSON prompt string * @param prompt - JSON prompt string
@@ -691,6 +691,74 @@ export async function callAI(
logger.debug('Claude cleaned response:', { cleanedContent }); logger.debug('Claude cleaned response:', { cleanedContent });
return JSON.parse(cleanedContent); return JSON.parse(cleanedContent);
} else if (provider === 'gemini') {
const requestBody = {
systemInstruction: {
parts: [{ text: systemMessage }],
},
contents: [
{
parts: [{ text: prompt }],
},
],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "OBJECT",
properties: {
recommendations: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
title: { type: "STRING" },
author: { type: "STRING" },
reason: { type: "STRING" },
},
required: ["title", "author", "reason"],
},
},
},
required: ["recommendations"],
},
},
};
logger.debug('Gemini request body:', { requestBody });
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Gemini API error', { status: response.status, error: errorText });
throw new Error(`Gemini API error: ${response.status} ${errorText}`);
}
const data = await response.json();
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!content) {
throw new Error('Invalid response format from Gemini API');
}
logger.debug('Gemini raw response:', { content });
// Clean potential markdown wrapping
const cleanedContent = content
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
logger.debug('Gemini cleaned response:', { cleanedContent });
return JSON.parse(cleanedContent);
} else if (provider === 'custom') { } else if (provider === 'custom') {
if (!baseUrl) { if (!baseUrl) {
throw new Error('Base URL is required for custom provider'); throw new Error('Base URL is required for custom provider');
+51 -11
View File
@@ -5,7 +5,9 @@
'use client'; 'use client';
import { useRef, useEffect, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { authenticatedFetcher } from '@/lib/utils/api'; import { authenticatedFetcher } from '@/lib/utils/api';
export interface Audiobook { export interface Audiobook {
@@ -57,20 +59,58 @@ export function useAudiobooks(type: 'popular' | 'new-releases', limit: number =
}; };
} }
export function useSearch(query: string, page: number = 1) { function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
const shouldFetch = query && query.length > 0; const seen = new Set<string>();
const endpoint = shouldFetch ? `/api/audiobooks/search?q=${encodeURIComponent(query)}&page=${page}` : null; return items.filter(item => {
if (seen.has(item.asin)) return false;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { seen.add(item.asin);
revalidateOnFocus: false, return true;
dedupingInterval: 30000, // Cache for 30 seconds
}); });
}
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 { return {
results: data?.results || [], results,
totalResults: data?.totalResults || 0, totalResults,
hasMore: data?.hasMore || false, hasMore,
isLoading: shouldFetch && isLoading, isLoading: isLoadingInitial,
isLoadingMore,
loadMore,
error, error,
}; };
} }
+52 -12
View File
@@ -5,7 +5,9 @@
'use client'; 'use client';
import { useRef, useEffect, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { authenticatedFetcher } from '@/lib/utils/api'; import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks'; import { Audiobook } from './useAudiobooks';
@@ -68,21 +70,59 @@ export function useAuthorDetail(asin: string | null) {
}; };
} }
export function useAuthorBooks(asin: string | null, authorName: string | null) { function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
const shouldFetch = asin && authorName; const seen = new Set<string>();
const endpoint = shouldFetch return items.filter(item => {
? `/api/authors/${asin}/books?name=${encodeURIComponent(authorName)}` if (seen.has(item.asin)) return false;
: null; seen.add(item.asin);
return true;
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache for 1 minute
}); });
}
export function useAuthorBooks(asin: string | null, authorName: string | null) {
const prevIdentityRef = useRef<string | null>(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 { return {
books: (data?.books || []) as Audiobook[], books,
totalBooks: data?.totalBooks || 0, totalBooks,
isLoading: !!shouldFetch && isLoading, hasMore,
isLoading: isLoadingInitial || (!!identity && isLoading),
isLoadingMore,
loadMore,
error, error,
}; };
} }
+56 -8
View File
@@ -5,7 +5,9 @@
'use client'; 'use client';
import { useRef, useEffect, useCallback } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { authenticatedFetcher } from '@/lib/utils/api'; import { authenticatedFetcher } from '@/lib/utils/api';
import { Audiobook } from './useAudiobooks'; import { Audiobook } from './useAudiobooks';
@@ -59,17 +61,63 @@ export function useSeriesSearch(query: string) {
}; };
} }
export function useSeriesDetail(asin: string | null) { function dedupeByAsin<T extends { asin: string }>(items: T[]): T[] {
const endpoint = asin ? `/api/series/${asin}` : null; const seen = new Set<string>();
return items.filter(item => {
const { data, error, isLoading } = useSWR(endpoint, authenticatedFetcher, { if (seen.has(item.asin)) return false;
revalidateOnFocus: false, seen.add(item.asin);
dedupingInterval: 300000, // Cache for 5 minutes return true;
}); });
}
export function useSeriesDetail(asin: string | null) {
const prevAsinRef = useRef<string | null>(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 { return {
series: (data?.series || null) as SeriesDetail | null, series,
isLoading, hasMore,
isLoading: isLoadingInitial || (!!asin && isLoading),
isLoadingMore,
loadMore,
error, error,
}; };
} }
+11 -4
View File
@@ -288,17 +288,17 @@ function parseSeriesPageSummary(
* Scrape a series page for full detail data including books and similar series. * Scrape a series page for full detail data including books and similar series.
* Used by the detail API endpoint. * Used by the detail API endpoint.
*/ */
export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | null> { export async function scrapeSeriesPage(asin: string, page: number = 1): Promise<(SeriesDetail & { hasMore: boolean; page: number }) | null> {
const service = getAudibleService(); const service = getAudibleService();
const region = service.getRegion(); const region = service.getRegion();
const baseUrl = service.getBaseUrl(); const baseUrl = service.getBaseUrl();
const langConfig = getLanguageForRegion(region); const langConfig = getLanguageForRegion(region);
logger.info(`Scraping series detail page: ${asin}`); logger.info(`Scraping series detail page: ${asin}, page ${page}`);
try { try {
const { data: response } = await service.fetch(`/series/${asin}`, { 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); const $ = cheerio.load(response.data);
@@ -316,10 +316,15 @@ export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | nul
// Use actual book count if we got more from scraping // Use actual book count if we got more from scraping
const bookCount = Math.max(summary.bookCount, books.length); const bookCount = Math.max(summary.bookCount, books.length);
// Calculate hasMore: use header bookCount if available, otherwise check if full page
const hasMore = bookCount > 0
? page * AUDIBLE_PAGE_SIZE < bookCount
: books.length >= AUDIBLE_PAGE_SIZE;
// Parse similar series ("Listeners also enjoyed" or similar section) // Parse similar series ("Listeners also enjoyed" or similar section)
const similarSeries = parseSimilarSeries($); 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 { return {
asin, asin,
@@ -332,6 +337,8 @@ export async function scrapeSeriesPage(asin: string): Promise<SeriesDetail | nul
books, books,
similarSeries, similarSeries,
audibleUrl: `${baseUrl}/series/${asin}`, audibleUrl: `${baseUrl}/series/${asin}`,
hasMore,
page,
}; };
} catch (error) { } catch (error) {
logger.error(`Failed to scrape series detail ${asin}`, { logger.error(`Failed to scrape series detail ${asin}`, {
+26 -29
View File
@@ -59,6 +59,13 @@ export interface AudibleSearchResult {
hasMore: boolean; hasMore: boolean;
} }
export interface AuthorBooksResult {
books: AudibleAudiobook[];
hasMore: boolean;
page: number;
totalResults: number;
}
export class AudibleService { export class AudibleService {
private client!: AxiosInstance; private client!: AxiosInstance;
private baseUrl: string = 'https://www.audible.com'; private baseUrl: string = 'https://www.audible.com';
@@ -564,7 +571,9 @@ export class AudibleService {
results: audiobooks, results: audiobooks,
totalResults, totalResults,
page, page,
hasMore: audiobooks.length > 0 && totalResults > page * AUDIBLE_PAGE_SIZE, hasMore: audiobooks.length > 0 && (totalResults > 0
? totalResults > page * AUDIBLE_PAGE_SIZE
: audiobooks.length >= AUDIBLE_PAGE_SIZE),
}; };
} catch (error) { } catch (error) {
logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) }); logger.error('Search failed', { error: error instanceof Error ? error.message : String(error) });
@@ -583,18 +592,16 @@ export class AudibleService {
* Uses Audible's searchAuthor parameter and paginates through all results. * Uses Audible's searchAuthor parameter and paginates through all results.
* Filters: (1) author link must contain the target ASIN, (2) language must be English. * Filters: (1) author link must contain the target ASIN, (2) language must be English.
*/ */
async searchByAuthorAsin(authorName: string, authorAsin: string): Promise<AudibleAudiobook[]> { async searchByAuthorAsin(authorName: string, authorAsin: string, page: number = 1): Promise<AuthorBooksResult> {
await this.initialize(); await this.initialize();
const MAX_PAGES = 10; const books: AudibleAudiobook[] = [];
const allBooks: AudibleAudiobook[] = [];
const seenAsins = new Set<string>(); const seenAsins = new Set<string>();
try { 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 } = await this.fetchWithRetry('/search', {
const { data: response, meta } = await this.fetchWithRetry('/search', {
params: { params: {
ipRedirectOverride: 'true', ipRedirectOverride: 'true',
searchAuthor: authorName, searchAuthor: authorName,
@@ -604,7 +611,9 @@ export class AudibleService {
}); });
const $ = cheerio.load(response.data); const $ = cheerio.load(response.data);
let pageResults = 0;
// Count raw items on page before filtering (for hasMore fallback)
const pageItemCount = $('.s-result-item, .productListItem').length;
$('.s-result-item, .productListItem').each((_index, element) => { $('.s-result-item, .productListItem').each((_index, element) => {
const $el = $(element); const $el = $(element);
@@ -613,7 +622,6 @@ export class AudibleService {
const langConfig = this.getLangConfig(); const langConfig = this.getLangConfig();
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() || const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
$el.find('.languageLabel').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 langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
const langMatch = langText.match(langLabelPattern); const langMatch = langText.match(langLabelPattern);
const language = langMatch?.[1]?.trim(); const language = langMatch?.[1]?.trim();
@@ -662,7 +670,7 @@ export class AudibleService {
$el.find('.a-icon-star span').first().text().trim(); $el.find('.a-icon-star span').first().text().trim();
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined; const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
allBooks.push({ books.push({
asin: bookAsin, asin: bookAsin,
title, title,
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes), author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
@@ -672,34 +680,23 @@ export class AudibleService {
durationMinutes, durationMinutes,
rating, rating,
}); });
pageResults++;
}); });
// Check if there are more pages // Check total results for pagination
const resultsText = $('.resultsInfo').text().trim(); const resultsText = $('.resultsInfo').text().trim();
const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0'); const totalResults = parseInt(resultsText.match(/of ([\d,]+)/)?.[1]?.replace(/,/g, '') || '0');
const hasMore = totalResults > page * AUDIBLE_PAGE_SIZE; // 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)`); logger.info(`Author books page ${page}: ${books.length} valid results (${totalResults} Audible total)`);
return { books, hasMore, page, totalResults };
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;
} catch (error) { } catch (error) {
logger.error(`Author books search failed for "${authorName}"`, { logger.error(`Author books search failed for "${authorName}"`, {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
collectedSoFar: allBooks.length,
}); });
// Return what we collected before the error return { books, hasMore: false, page, totalResults: 0 };
return allBooks;
} }
} }
@@ -9,6 +9,7 @@ import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service'; import { getDownloadClientManager } from '../services/download-client-manager.service';
import { ProwlarrService } from '../integrations/prowlarr.service'; import { ProwlarrService } from '../integrations/prowlarr.service';
import { RMABLogger } from '../utils/logger'; import { RMABLogger } from '../utils/logger';
import { isTransientConnectionError } from '../utils/connection-errors';
/** /**
* Process download job * Process download job
@@ -121,7 +122,13 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
} catch (error) { } catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
// Update request status to failed 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({ await prisma.request.update({
where: { id: requestId }, where: { id: requestId },
data: { data: {
@@ -130,6 +137,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
updatedAt: new Date(), updatedAt: new Date(),
}, },
}); });
}
throw error; throw error;
} }
@@ -10,6 +10,7 @@ import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
import { getConfigService } from '../services/config.service'; import { getConfigService } from '../services/config.service';
import { getDownloadClientManager } from '../services/download-client-manager.service'; import { getDownloadClientManager } from '../services/download-client-manager.service';
import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface';
import { isTransientConnectionError } from '../utils/connection-errors';
/** /**
* Process monitor download job * Process monitor download job
@@ -20,6 +21,12 @@ import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-
const BASE_POLL_INTERVAL = 10; const BASE_POLL_INTERVAL = 10;
/** Maximum polling interval in seconds (5 minutes) */ /** Maximum polling interval in seconds (5 minutes) */
const MAX_POLL_INTERVAL = 300; 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. * 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<any> { export async function processMonitorDownload(payload: MonitorDownloadPayload): Promise<any> {
const { requestId, downloadHistoryId, downloadClientId, downloadClient, jobId, 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'); const logger = RMABLogger.forJob(jobId, 'MonitorDownload');
@@ -288,16 +296,65 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
} catch (error) { } catch (error) {
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown 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 errorMessage = error instanceof Error ? error.message : '';
const isNotFound = errorMessage.includes('not found'); const isNotFound = errorMessage.includes('not found');
const isConnectionError = isTransientConnectionError(error);
if (isNotFound) { if (isNotFound) {
// Transient error - don't mark request as failed, let Bull retry // PATH 1: "Not found" — transient race condition.
// The request stays in 'downloading' status until Bull exhausts all retries // Don't mark request as failed; let Bull retry the same job.
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`); logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
throw error;
}
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 { } else {
// Permanent error - mark request as failed immediately const delay = getBackoffDelay(failureCount);
logger.warn(
`Download client unreachable (${failureCount}/${MAX_CONNECTION_FAILURES}), ` +
`retrying in ${delay}s for request ${requestId}`,
{ error: errorMessage }
);
const jobQueue = getJobQueueService();
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,
};
}
}
// PATH 3: Permanent error (or connection failures exhausted).
// Mark request as failed immediately.
const failureMessage = errorMessage || 'Monitor download failed'; const failureMessage = errorMessage || 'Monitor download failed';
await prisma.request.update({ await prisma.request.update({
where: { id: requestId }, where: { id: requestId },
@@ -326,13 +383,12 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
request.audiobook.author, request.audiobook.author,
request.user.plexUsername || 'Unknown User', request.user.plexUsername || 'Unknown User',
failureMessage failureMessage
).catch((error) => { ).catch((notifError) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); logger.error('Failed to queue notification', { error: notifError instanceof Error ? notifError.message : String(notifError) });
}); });
} }
}
// Rethrow to trigger Bull's retry mechanism // Rethrow to trigger Bull's retry mechanism as a safety net
throw error; throw error;
} }
} }
+74 -2
View File
@@ -22,7 +22,7 @@ import { removeEmptyParentDirectories } from '../utils/cleanup-helpers';
* Handles both audiobook and ebook request types with appropriate branching * Handles both audiobook and ebook request types with appropriate branching
*/ */
export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> { export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promise<any> {
const { requestId, audiobookId, downloadPath, jobId } = payload; const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
const logger = RMABLogger.forJob(jobId, 'OrganizeFiles'); 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) // Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger); await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
// Cleanup source files if requested (manual import feature)
if (cleanupSource) {
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
}
return { return {
success: true, success: true,
message: 'Files organized successfully', message: 'Files organized successfully',
@@ -467,7 +472,7 @@ async function processEbookOrganization(
request: { id: string; userId: string; type: string; user: { plexUsername: string | null } }, request: { id: string; userId: string; type: string; user: { plexUsername: string | null } },
logger: RMABLogger logger: RMABLogger
): Promise<any> { ): Promise<any> {
const { requestId, audiobookId, downloadPath, jobId } = payload; const { requestId, audiobookId, downloadPath, jobId, cleanupSource } = payload;
logger.info(`Processing ebook organization for request ${requestId}`); 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) // Cleanup downloads if configured (uses IDownloadClient.postProcess for client-specific cleanup)
await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger); await cleanupDownloadAfterOrganize(requestId, downloadPath, configService, jobId, logger);
// Cleanup source files if requested (manual import feature)
if (cleanupSource) {
await cleanupSourceAfterOrganize(downloadPath, configService, jobId, logger);
}
return { return {
success: true, success: true,
message: 'Ebook organized successfully', 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<void> {
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 // HELPER FUNCTIONS
// ========================================================================= // =========================================================================
@@ -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 // Get enabled indexers from configuration
const { getConfigService } = await import('../services/config.service'); const { getConfigService } = await import('../services/config.service');
const configService = getConfigService(); const configService = getConfigService();
@@ -77,7 +84,11 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
// Get Prowlarr service // Get Prowlarr service
const prowlarr = await getProwlarrService(); const prowlarr = await getProwlarrService();
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}"`); logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
}
// Search Prowlarr for each group and combine results // Search Prowlarr for each group and combine results
const allResults = []; const allResults = [];
@@ -87,7 +98,7 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`); logger.info(`Searching group ${i + 1}/${groups.length}: ${getGroupDescription(group)}`);
try { try {
const groupResults = await prowlarr.searchWithVariations(audiobook.title, audiobook.author, { const groupResults = await prowlarr.searchWithVariations(effectiveSearchTitle, audiobook.author, {
categories: group.categories, categories: group.categories,
indexerIds: group.indexerIds, indexerIds: group.indexerIds,
minSeeders: 1, // Only torrents with at least 1 seeder minSeeders: 1, // Only torrents with at least 1 seeder
+31 -2
View File
@@ -66,6 +66,7 @@ export interface MonitorDownloadPayload extends JobPayload {
lastProgress?: number; // Previous poll's progress (0-100) for stall detection lastProgress?: number; // Previous poll's progress (0-100) for stall detection
stallCount?: number; // Consecutive polls with no progress change (drives backoff) stallCount?: number; // Consecutive polls with no progress change (drives backoff)
pathWaitCount?: number; // Consecutive polls waiting for content_path to relocate to save_path 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 { export interface OrganizeFilesPayload extends JobPayload {
@@ -73,6 +74,7 @@ export interface OrganizeFilesPayload extends JobPayload {
audiobookId: string; audiobookId: string;
downloadPath: string; downloadPath: string;
targetPath?: string; // Optional - not used by processor (reads from database config) 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 { 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) }); 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) => { this.queue.on('stalled', async (job: BullJob) => {
@@ -569,7 +594,8 @@ export class JobQueueService {
delaySeconds: number = 0, delaySeconds: number = 0,
lastProgress?: number, lastProgress?: number,
stallCount?: number, stallCount?: number,
pathWaitCount?: number pathWaitCount?: number,
connectionFailureCount?: number
): Promise<string> { ): Promise<string> {
return await this.addJob( return await this.addJob(
'monitor_download', 'monitor_download',
@@ -581,6 +607,7 @@ export class JobQueueService {
lastProgress, lastProgress,
stallCount, stallCount,
pathWaitCount, pathWaitCount,
connectionFailureCount,
} as MonitorDownloadPayload, } as MonitorDownloadPayload,
{ {
priority: 5, // Medium priority priority: 5, // Medium priority
@@ -597,7 +624,8 @@ export class JobQueueService {
requestId: string, requestId: string,
audiobookId: string, audiobookId: string,
downloadPath: string, downloadPath: string,
targetPath?: string targetPath?: string,
cleanupSource?: boolean
): Promise<string> { ): Promise<string> {
return await this.addJob( return await this.addJob(
'organize_files', 'organize_files',
@@ -606,6 +634,7 @@ export class JobQueueService {
audiobookId, audiobookId,
downloadPath, downloadPath,
targetPath, // Not used by processor targetPath, // Not used by processor
cleanupSource,
} as OrganizeFilesPayload, } as OrganizeFilesPayload,
{ {
priority: 8, priority: 8,
@@ -45,13 +45,31 @@ export class AppriseProvider implements INotificationProvider {
const meta = getEventMeta(payload.event); const meta = getEventMeta(payload.event);
const { title, body } = this.formatMessage(payload); const { title, body } = this.formatMessage(payload);
const serverUrl = appriseConfig.serverUrl.replace(/\/+$/, ''); // Parse URL to extract embedded HTTP Basic Auth credentials (e.g. https://user:pass@host/)
const notificationType = SEVERITY_TYPES[meta.severity]; let serverUrl: string;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', '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) { if (appriseConfig.authToken) {
headers['Authorization'] = `Bearer ${appriseConfig.authToken}`; headers['Authorization'] = `Bearer ${appriseConfig.authToken}`;
} }
+80
View File
@@ -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;
}
@@ -29,6 +29,10 @@ vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
}) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null), }) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null),
})); }));
vi.mock('@/app/admin/components/AdjustSearchTermsModal', () => ({
AdjustSearchTermsModal: () => null,
}));
describe('RequestActionsDropdown', () => { describe('RequestActionsDropdown', () => {
it('exposes manual search, interactive search, cancel, and delete actions', async () => { it('exposes manual search, interactive search, cancel, and delete actions', async () => {
const onManualSearch = vi.fn().mockResolvedValue(undefined); const onManualSearch = vi.fn().mockResolvedValue(undefined);
+40 -20
View File
@@ -11,14 +11,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetMockAuthState } from '../helpers/mock-auth'; import { resetMockAuthState } from '../helpers/mock-auth';
import { resetMockRouter } from '../helpers/mock-next-navigation'; import { resetMockRouter } from '../helpers/mock-next-navigation';
const loadMoreMock = vi.hoisted(() => vi.fn());
const useSearchMock = vi.hoisted(() => vi.fn()); const useSearchMock = vi.hoisted(() => vi.fn());
const usePreferencesMock = vi.hoisted(() => ({ const usePreferencesMock = vi.hoisted(() => ({
cardSize: 5, cardSize: 5,
setCardSize: vi.fn(), setCardSize: vi.fn(),
squareCovers: false,
setSquareCovers: vi.fn(),
hideAvailable: false,
setHideAvailable: vi.fn(),
})); }));
vi.mock('@/lib/hooks/useAudiobooks', () => ({ vi.mock('@/lib/hooks/useAudiobooks', () => ({
useSearch: useSearchMock, useSearch: useSearchMock,
Audiobook: {},
})); }));
vi.mock('@/contexts/PreferencesContext', () => ({ vi.mock('@/contexts/PreferencesContext', () => ({
@@ -49,8 +55,30 @@ vi.mock('@/components/audiobooks/AudiobookGrid', () => ({
), ),
})); }));
vi.mock('@/components/ui/CardSizeControls', () => ({ vi.mock('@/components/ui/SectionToolbar', () => ({
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />, SectionToolbar: () => <div data-testid="section-toolbar" />,
}));
vi.mock('@/components/ui/LoadMoreBar', () => ({
LoadMoreBar: ({
hasMore,
isLoading,
onLoadMore,
}: {
loadedCount: number;
totalCount?: number;
hasMore: boolean;
isLoading: boolean;
onLoadMore: () => void;
itemLabel?: string;
}) =>
hasMore ? (
<button onClick={onLoadMore} disabled={isLoading}>
Load more
</button>
) : (
<div data-testid="all-loaded">All loaded</div>
),
})); }));
describe('SearchPage', () => { describe('SearchPage', () => {
@@ -58,6 +86,7 @@ describe('SearchPage', () => {
resetMockAuthState(); resetMockAuthState();
resetMockRouter(); resetMockRouter();
useSearchMock.mockReset(); useSearchMock.mockReset();
loadMoreMock.mockReset();
usePreferencesMock.cardSize = 5; usePreferencesMock.cardSize = 5;
usePreferencesMock.setCardSize.mockReset(); usePreferencesMock.setCardSize.mockReset();
vi.useFakeTimers(); vi.useFakeTimers();
@@ -74,34 +103,25 @@ describe('SearchPage', () => {
totalResults: 0, totalResults: 0,
hasMore: false, hasMore: false,
isLoading: false, isLoading: false,
isLoadingMore: false,
loadMore: loadMoreMock,
}); });
const { default: SearchPage } = await import('@/app/search/page'); const { default: SearchPage } = await import('@/app/search/page');
render(<SearchPage />); render(<SearchPage />);
expect(screen.getByText('Start typing to search for audiobooks')).toBeInTheDocument(); 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 () => { it('debounces search input and loads more results', async () => {
useSearchMock.mockImplementation((query: string, page: number) => { useSearchMock.mockReturnValue({
if (!query) {
return { results: [], totalResults: 0, hasMore: false, isLoading: false };
}
if (page === 1) {
return {
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }], results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
totalResults: 2, totalResults: 2,
hasMore: true, hasMore: true,
isLoading: false, isLoading: false,
}; isLoadingMore: false,
} loadMore: loadMoreMock,
return {
results: [{ asin: 'a2', title: 'Book Two', author: 'Author' }],
totalResults: 2,
hasMore: false,
isLoading: false,
};
}); });
const { default: SearchPage } = await import('@/app/search/page'); const { default: SearchPage } = await import('@/app/search/page');
@@ -115,11 +135,11 @@ describe('SearchPage', () => {
}); });
expect(screen.getByText('Search Results')).toBeInTheDocument(); 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'); 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();
}); });
}); });
+18 -7
View File
@@ -10,12 +10,17 @@ import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const useSWRMock = vi.hoisted(() => vi.fn()); const useSWRMock = vi.hoisted(() => vi.fn());
const useSWRInfiniteMock = vi.hoisted(() => vi.fn());
const authenticatedFetcherMock = vi.hoisted(() => vi.fn()); const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
vi.mock('swr', () => ({ vi.mock('swr', () => ({
default: useSWRMock, default: useSWRMock,
})); }));
vi.mock('swr/infinite', () => ({
default: useSWRInfiniteMock,
}));
vi.mock('@/lib/utils/api', () => ({ vi.mock('@/lib/utils/api', () => ({
authenticatedFetcher: authenticatedFetcherMock, authenticatedFetcher: authenticatedFetcherMock,
})); }));
@@ -27,6 +32,7 @@ const HookProbe = ({ label, value }: { label: string; value: any }) => (
describe('useAudiobooks hooks', () => { describe('useAudiobooks hooks', () => {
beforeEach(() => { beforeEach(() => {
useSWRMock.mockReset(); useSWRMock.mockReset();
useSWRInfiniteMock.mockReset();
authenticatedFetcherMock.mockReset(); authenticatedFetcherMock.mockReset();
vi.resetModules(); vi.resetModules();
}); });
@@ -60,25 +66,30 @@ describe('useAudiobooks hooks', () => {
}); });
it('skips search when the query is empty', async () => { 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 { useSearch } = await import('@/lib/hooks/useAudiobooks');
const Probe = () => { const Probe = () => {
const result = useSearch('', 1); const result = useSearch('');
return <HookProbe label="search" value={result} />; return <HookProbe label="search" value={result} />;
}; };
render(<Probe />); render(<Probe />);
expect(useSWRMock).toHaveBeenCalledWith( // useSWRInfinite should be called with a key function
null, expect(useSWRInfiniteMock).toHaveBeenCalled();
authenticatedFetcherMock,
expect.objectContaining({ dedupingInterval: 30000 })
);
const parsed = JSON.parse(screen.getByTestId('search').textContent || '{}'); const parsed = JSON.parse(screen.getByTestId('search').textContent || '{}');
expect(parsed.isLoading).toBeFalsy(); expect(parsed.isLoading).toBeFalsy();
expect(parsed.results).toEqual([]);
}); });
it('requests audiobook details when an ASIN is provided', async () => { it('requests audiobook details when an ASIN is provided', async () => {
+96
View File
@@ -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', () => { describe('notification types by event', () => {
it('maps event types to correct Apprise notification types', async () => { it('maps event types to correct Apprise notification types', async () => {
fetchMock.mockResolvedValue({ fetchMock.mockResolvedValue({