mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfd624e120 | |||
| b559835390 | |||
| d25a6ebf79 | |||
| b3dad47aba | |||
| 7891e31893 | |||
| bff74446fe | |||
| 038c92e49f |
+1
-1
@@ -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;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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,123 +592,111 @@ 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,
|
pageSize: AUDIBLE_PAGE_SIZE,
|
||||||
pageSize: AUDIBLE_PAGE_SIZE,
|
page,
|
||||||
page,
|
},
|
||||||
},
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
|
||||||
|
// Count raw items on page before filtering (for hasMore fallback)
|
||||||
|
const pageItemCount = $('.s-result-item, .productListItem').length;
|
||||||
|
|
||||||
|
$('.s-result-item, .productListItem').each((_index, element) => {
|
||||||
|
const $el = $(element);
|
||||||
|
|
||||||
|
// --- Language filter: require matching language for region ---
|
||||||
|
const langConfig = this.getLangConfig();
|
||||||
|
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
|
||||||
|
$el.find('.languageLabel').text().trim();
|
||||||
|
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
|
||||||
|
const langMatch = langText.match(langLabelPattern);
|
||||||
|
const language = langMatch?.[1]?.trim();
|
||||||
|
if (!language || !isAcceptedLanguage(language, langConfig)) return;
|
||||||
|
|
||||||
|
// --- Author ASIN filter: verify target ASIN in author links ---
|
||||||
|
const authorLinks = $el.find('a[href*="/author/"]');
|
||||||
|
let hasMatchingAuthor = false;
|
||||||
|
authorLinks.each((_i, link) => {
|
||||||
|
const href = $(link).attr('href') || '';
|
||||||
|
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
||||||
|
if (asinMatch && asinMatch[1] === authorAsin) {
|
||||||
|
hasMatchingAuthor = true;
|
||||||
|
return false; // break .each()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
if (!hasMatchingAuthor) return;
|
||||||
|
|
||||||
const $ = cheerio.load(response.data);
|
// --- Extract book ASIN ---
|
||||||
let pageResults = 0;
|
const bookAsin = $el.find('li').attr('data-asin') ||
|
||||||
|
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
||||||
|
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
||||||
|
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
||||||
|
seenAsins.add(bookAsin);
|
||||||
|
|
||||||
$('.s-result-item, .productListItem').each((_index, element) => {
|
// --- Parse book details ---
|
||||||
const $el = $(element);
|
const title = $el.find('h2').first().text().trim() ||
|
||||||
|
$el.find('h3 a').text().trim() ||
|
||||||
|
$el.find('.bc-heading a').text().trim();
|
||||||
|
|
||||||
// --- Language filter: require matching language for region ---
|
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
||||||
const langConfig = this.getLangConfig();
|
$el.find('.authorLabel').text().trim() ||
|
||||||
const langText = $el.find(buildContainsSelector('span', langConfig.scraping.languageLabels)).text().trim() ||
|
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
||||||
$el.find('.languageLabel').text().trim();
|
|
||||||
// Extract language value (e.g. "Language: English" -> "English", "Sprache: Deutsch" -> "Deutsch")
|
|
||||||
const langLabelPattern = new RegExp(`(?:${langConfig.scraping.languageLabels.map(l => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\s*(.+)`, 'i');
|
|
||||||
const langMatch = langText.match(langLabelPattern);
|
|
||||||
const language = langMatch?.[1]?.trim();
|
|
||||||
if (!language || !isAcceptedLanguage(language, langConfig)) return;
|
|
||||||
|
|
||||||
// --- Author ASIN filter: verify target ASIN in author links ---
|
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
||||||
const authorLinks = $el.find('a[href*="/author/"]');
|
$el.find('.narratorLabel').text().trim();
|
||||||
let hasMatchingAuthor = false;
|
|
||||||
authorLinks.each((_i, link) => {
|
|
||||||
const href = $(link).attr('href') || '';
|
|
||||||
const asinMatch = href.match(/\/author\/[^\/]+\/([A-Z0-9]{10})/);
|
|
||||||
if (asinMatch && asinMatch[1] === authorAsin) {
|
|
||||||
hasMatchingAuthor = true;
|
|
||||||
return false; // break .each()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!hasMatchingAuthor) return;
|
|
||||||
|
|
||||||
// --- Extract book ASIN ---
|
const coverArtUrl = $el.find('img').attr('src') || '';
|
||||||
const bookAsin = $el.find('li').attr('data-asin') ||
|
|
||||||
$el.find('a[href*="/pd/"]').attr('href')?.match(/\/pd\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
|
||||||
$el.find('a[href*="/ac/"]').attr('href')?.match(/\/ac\/[^\/]+\/([A-Z0-9]{10})/)?.[1] ||
|
|
||||||
$el.find('a').attr('href')?.match(/\/(?:pd|ac)\/[^\/]+\/([A-Z0-9]{10})/)?.[1] || '';
|
|
||||||
if (!bookAsin || seenAsins.has(bookAsin)) return;
|
|
||||||
seenAsins.add(bookAsin);
|
|
||||||
|
|
||||||
// --- Parse book details ---
|
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
||||||
const title = $el.find('h2').first().text().trim() ||
|
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
||||||
$el.find('h3 a').text().trim() ||
|
const durationMinutes = this.parseRuntime(runtimeText);
|
||||||
$el.find('.bc-heading a').text().trim();
|
|
||||||
|
|
||||||
const authorText = $el.find('a[href*="/author/"]').first().text().trim() ||
|
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
||||||
$el.find('.authorLabel').text().trim() ||
|
$el.find('.a-icon-star span').first().text().trim();
|
||||||
$el.find('.bc-size-small .bc-text-bold').first().text().trim();
|
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
||||||
|
|
||||||
const narratorText = $el.find('a[href*="searchNarrator="]').first().text().trim() ||
|
books.push({
|
||||||
$el.find('.narratorLabel').text().trim();
|
asin: bookAsin,
|
||||||
|
title,
|
||||||
const coverArtUrl = $el.find('img').attr('src') || '';
|
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
||||||
|
authorAsin,
|
||||||
const runtimeText = $el.find('.runtimeLabel').text().trim() ||
|
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
||||||
$el.find(buildContainsSelector('span', langConfig.scraping.lengthLabels)).text().trim();
|
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
||||||
const durationMinutes = this.parseRuntime(runtimeText);
|
durationMinutes,
|
||||||
|
rating,
|
||||||
const ratingText = $el.find('.ratingsLabel').text().trim() ||
|
|
||||||
$el.find('.a-icon-star span').first().text().trim();
|
|
||||||
const rating = ratingText ? parseFloat(ratingText.split(' ')[0]) : undefined;
|
|
||||||
|
|
||||||
allBooks.push({
|
|
||||||
asin: bookAsin,
|
|
||||||
title,
|
|
||||||
author: stripPrefixes(authorText, langConfig.scraping.authorPrefixes),
|
|
||||||
authorAsin,
|
|
||||||
narrator: stripPrefixes(narratorText, langConfig.scraping.narratorPrefixes),
|
|
||||||
coverArtUrl: coverArtUrl.replace(/\._.*_\./, '._SL500_.'),
|
|
||||||
durationMinutes,
|
|
||||||
rating,
|
|
||||||
});
|
|
||||||
|
|
||||||
pageResults++;
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 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,15 +122,22 @@ 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)) {
|
||||||
await prisma.request.update({
|
// Connection error — don't mark request as failed yet.
|
||||||
where: { id: requestId },
|
// Bull will retry this job (3 attempts with exponential backoff).
|
||||||
data: {
|
// If all retries are exhausted, the global failed handler marks it failed.
|
||||||
status: 'failed',
|
logger.warn(`Download client unreachable for request ${requestId}, allowing Bull to retry`);
|
||||||
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
|
} else {
|
||||||
updatedAt: new Date(),
|
// Permanent error — mark request as failed immediately
|
||||||
},
|
await prisma.request.update({
|
||||||
});
|
where: { id: requestId },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: error instanceof Error ? error.message : 'Failed to add download to client',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
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,51 +296,99 @@ 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`);
|
||||||
} else {
|
throw error;
|
||||||
// Permanent error - mark request as failed immediately
|
}
|
||||||
const failureMessage = errorMessage || 'Monitor download failed';
|
|
||||||
await prisma.request.update({
|
|
||||||
where: { id: requestId },
|
|
||||||
data: {
|
|
||||||
status: 'failed',
|
|
||||||
errorMessage: failureMessage,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send notification for request failure
|
if (isConnectionError) {
|
||||||
const request = await prisma.request.findUnique({
|
// PATH 2: Connection failure — download client is temporarily unreachable.
|
||||||
where: { id: requestId },
|
// Instead of failing the download, self-schedule the next poll with backoff.
|
||||||
include: {
|
// This reuses the same adaptive backoff as stalled downloads, giving the
|
||||||
audiobook: true,
|
// client time to recover (restart, network blip, update, etc.).
|
||||||
user: { select: { plexUsername: true } },
|
const failureCount = (prevConnectionFailures ?? 0) + 1;
|
||||||
},
|
|
||||||
});
|
if (failureCount >= MAX_CONNECTION_FAILURES) {
|
||||||
|
// Exhausted patience — treat as permanent failure
|
||||||
|
logger.error(
|
||||||
|
`Download client unreachable for ${failureCount} consecutive checks, giving up on request ${requestId}`
|
||||||
|
);
|
||||||
|
// Fall through to permanent failure handling below
|
||||||
|
} else {
|
||||||
|
const delay = getBackoffDelay(failureCount);
|
||||||
|
logger.warn(
|
||||||
|
`Download client unreachable (${failureCount}/${MAX_CONNECTION_FAILURES}), ` +
|
||||||
|
`retrying in ${delay}s for request ${requestId}`,
|
||||||
|
{ error: errorMessage }
|
||||||
|
);
|
||||||
|
|
||||||
if (request) {
|
|
||||||
const jobQueue = getJobQueueService();
|
const jobQueue = getJobQueueService();
|
||||||
await jobQueue.addNotificationJob(
|
await jobQueue.addMonitorJob(
|
||||||
'request_error',
|
requestId,
|
||||||
request.id,
|
downloadHistoryId,
|
||||||
request.audiobook.title,
|
downloadClientId,
|
||||||
request.audiobook.author,
|
downloadClient,
|
||||||
request.user.plexUsername || 'Unknown User',
|
delay,
|
||||||
failureMessage
|
prevProgress,
|
||||||
).catch((error) => {
|
prevStallCount ?? 0,
|
||||||
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
|
prevPathWaitCount,
|
||||||
});
|
failureCount
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return success — the monitoring loop continues via the new job.
|
||||||
|
// Do NOT throw: that would trigger Bull's retry on this job as well.
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
completed: false,
|
||||||
|
message: `Download client unreachable, will retry in ${delay}s`,
|
||||||
|
requestId,
|
||||||
|
connectionFailureCount: failureCount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rethrow to trigger Bull's retry mechanism
|
// PATH 3: Permanent error (or connection failures exhausted).
|
||||||
|
// Mark request as failed immediately.
|
||||||
|
const failureMessage = errorMessage || 'Monitor download failed';
|
||||||
|
await prisma.request.update({
|
||||||
|
where: { id: requestId },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: failureMessage,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send notification for request failure
|
||||||
|
const request = await prisma.request.findUnique({
|
||||||
|
where: { id: requestId },
|
||||||
|
include: {
|
||||||
|
audiobook: true,
|
||||||
|
user: { select: { plexUsername: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request) {
|
||||||
|
const jobQueue = getJobQueueService();
|
||||||
|
await jobQueue.addNotificationJob(
|
||||||
|
'request_error',
|
||||||
|
request.id,
|
||||||
|
request.audiobook.title,
|
||||||
|
request.audiobook.author,
|
||||||
|
request.user.plexUsername || 'Unknown User',
|
||||||
|
failureMessage
|
||||||
|
).catch((notifError) => {
|
||||||
|
logger.error('Failed to queue notification', { error: notifError instanceof Error ? notifError.message : String(notifError) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rethrow to trigger Bull's retry mechanism as a safety net
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
|
if (requestRecord?.customSearchTerms) {
|
||||||
|
logger.info(`Searching with custom terms: "${effectiveSearchTitle}" (original: "${audiobook.title}") by "${audiobook.author}"`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Searching for: "${audiobook.title}" by "${audiobook.author}"`);
|
||||||
|
}
|
||||||
|
|
||||||
// Search Prowlarr for each group and combine results
|
// 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
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
|
||||||
return { results: [], totalResults: 0, hasMore: false, isLoading: false };
|
totalResults: 2,
|
||||||
}
|
hasMore: true,
|
||||||
if (page === 1) {
|
isLoading: false,
|
||||||
return {
|
isLoadingMore: false,
|
||||||
results: [{ asin: 'a1', title: 'Book One', author: 'Author' }],
|
loadMore: loadMoreMock,
|
||||||
totalResults: 2,
|
|
||||||
hasMore: true,
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user